Ver código fonte

feat: delegate all execution to FluidNC firmware; drop serial and host GPIO LEDs

Rework the backend into a thin proxy + status observer over the FluidNC
firmware HTTP API. The board now owns kinematics, .thr playback, playlist
sequencing, pre-execution clears, quiet hours, auto-home and homing; the
backend's job is the continuous/historical layer the headless firmware omits
(previews, play history, MQTT/HA, WLED, mDNS multi-table, file management).

Execution (new modules/core/execution.py):
- Command proxy: run_pattern ($Sand/Run), start_playlist ($Playlist/* NVS +
  $Playlist/Run, idle-gated so run-while-running stops first), stop/pause/
  resume/skip, set_speed, move_polar.
- translate_status(): pure /sand_status -> /ws/status mapping (strips GRBL
  substate suffixes like "Hold:0").
- BoardObserver: single poll loop (1s active / 2s idle) that logs play
  history on file transitions, runs the clear-speed shim, the WLED
  quiet-hours watcher, and broadcasts. Replaces the host sequencing loop,
  board_status_poller and broadcast_progress.

Removed (~1900 lines): pattern_manager execution core + MotionControlThread,
playlist_manager.run_playlist, state stop/skip event machinery and the
serial/kinematics/dw_led/auto_play fields, fluidnc_config.py, the dw_leds
NeoPixel stack, the debug-serial block, /api/fluidnc/*, /controller_restart,
SetupPage.tsx. Live queue editing dropped (/reorder_playlist + /add_to_queue
removed; NowPlayingBar queue read-only; dnd-kit removed). Serial terminal
replaced by a firmware command console (POST /api/board/command). Dropped
pyserial and all Adafruit/RPi GPIO deps.

Settings: board NVS is canonical for table-behavior settings shared with the
mobile apps. New modules/core/board_settings.py syncs auto-play on boot
($Playlist/Autostart*), Still Sands quiet hours ($Sands/*), the board clock,
and mirrors playlists/clear files to the board SD. Web Settings adapted from
the mobile app; "Device Connection" is now a board-address field.

LEDs: new board provider (modules/led/board_led_controller.py) drives the
table's own ring via the firmware, duck-typed to the existing /api/dw_leds/*
contract. Added the firmware-native "ball" tracker (blob that follows the
sand ball) with blob/background colour, brightness, direction, alignment,
glow size and background sub-effect; all params persist to board NVS. WLED
kept; host GPIO NeoPixels removed.

Also: atomic metadata-cache writes (fixes truncation on kill mid-write that
forced full preview regeneration).

Verified live on the FluidNC board and via unit tests (143 passing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
tuanchris 1 semana atrás
pai
commit
613070a8ce
54 arquivos alterados com 11599 adições e 18932 exclusões
  1. 1 56
      frontend/package-lock.json
  2. 0 3
      frontend/package.json
  3. 0 2
      frontend/src/App.tsx
  4. 1171 1376
      frontend/src/components/NowPlayingBar.tsx
  5. 1 1
      frontend/src/components/layout/Layout.tsx
  6. 0 40
      frontend/src/pages/BrowsePage.tsx
  7. 202 26
      frontend/src/pages/LEDPage.tsx
  8. 2289 2526
      frontend/src/pages/SettingsPage.tsx
  9. 0 914
      frontend/src/pages/SetupPage.tsx
  10. 682 858
      frontend/src/pages/TableControlPage.tsx
  11. 3 2
      frontend/src/stores/useStatusStore.ts
  12. 0 20
      frontend/src/test/mocks/handlers.ts
  13. 0 3
      frontend/vite.config.ts
  14. 3553 4291
      main.py
  15. 19 51
      modules/connection/connection_manager.py
  16. 27 1
      modules/connection/fluidnc_client.py
  17. 0 337
      modules/connection/fluidnc_config.py
  18. 390 0
      modules/core/board_settings.py
  19. 46 21
      modules/core/cache_manager.py
  20. 652 0
      modules/core/execution.py
  21. 15 1456
      modules/core/pattern_manager.py
  22. 10 66
      modules/core/playlist_manager.py
  23. 4 279
      modules/core/state.py
  24. 241 0
      modules/led/board_led_controller.py
  25. 0 659
      modules/led/dw_led_controller.py
  26. 0 4
      modules/led/dw_leds/__init__.py
  27. 0 1
      modules/led/dw_leds/effects/__init__.py
  28. 0 1131
      modules/led/dw_leds/effects/basic_effects.py
  29. 0 154
      modules/led/dw_leds/segment.py
  30. 0 1
      modules/led/dw_leds/utils/__init__.py
  31. 0 234
      modules/led/dw_leds/utils/colors.py
  32. 0 766
      modules/led/dw_leds/utils/palettes.py
  33. 144 177
      modules/led/led_interface.py
  34. 1011 1011
      modules/mqtt/handler.py
  35. 43 58
      modules/mqtt/utils.py
  36. 0 11
      requirements.txt
  37. 0 0
      static/dist/assets/index-CHzltdTQ.css
  38. 0 0
      static/dist/assets/index-Djn5LR-N.js
  39. 0 0
      static/dist/assets/index-DpUgdjlV.js
  40. 0 0
      static/dist/assets/index-RF2BgLvM.css
  41. 2 2
      static/dist/index.html
  42. 0 0
      static/dist/sw.js
  43. 0 1
      tests/integration/__init__.py
  44. 0 144
      tests/integration/conftest.py
  45. 0 475
      tests/integration/test_hardware.py
  46. 0 576
      tests/integration/test_playback_controls.py
  47. 0 529
      tests/integration/test_playlist.py
  48. 8 21
      tests/unit/test_api_patterns.py
  49. 36 26
      tests/unit/test_api_playlists.py
  50. 184 270
      tests/unit/test_api_status.py
  51. 96 0
      tests/unit/test_board_led_controller.py
  52. 182 0
      tests/unit/test_board_settings.py
  53. 342 0
      tests/unit/test_execution.py
  54. 245 352
      tests/unit/test_pattern_manager.py

+ 1 - 56
frontend/package-lock.json

@@ -7,10 +7,8 @@
     "": {
       "name": "frontend",
       "version": "0.0.0",
+      "license": "GPL-3.0-or-later",
       "dependencies": {
-        "@dnd-kit/core": "^6.3.1",
-        "@dnd-kit/sortable": "^10.0.0",
-        "@dnd-kit/utilities": "^3.2.2",
         "@fontsource/noto-sans": "^5.2.10",
         "@fontsource/plus-jakarta-sans": "^5.2.8",
         "@radix-ui/react-accordion": "^1.2.12",
@@ -1958,59 +1956,6 @@
         "node": ">=18"
       }
     },
-    "node_modules/@dnd-kit/accessibility": {
-      "version": "3.1.1",
-      "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
-      "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
-      "license": "MIT",
-      "dependencies": {
-        "tslib": "^2.0.0"
-      },
-      "peerDependencies": {
-        "react": ">=16.8.0"
-      }
-    },
-    "node_modules/@dnd-kit/core": {
-      "version": "6.3.1",
-      "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
-      "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
-      "license": "MIT",
-      "dependencies": {
-        "@dnd-kit/accessibility": "^3.1.1",
-        "@dnd-kit/utilities": "^3.2.2",
-        "tslib": "^2.0.0"
-      },
-      "peerDependencies": {
-        "react": ">=16.8.0",
-        "react-dom": ">=16.8.0"
-      }
-    },
-    "node_modules/@dnd-kit/sortable": {
-      "version": "10.0.0",
-      "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
-      "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
-      "license": "MIT",
-      "dependencies": {
-        "@dnd-kit/utilities": "^3.2.2",
-        "tslib": "^2.0.0"
-      },
-      "peerDependencies": {
-        "@dnd-kit/core": "^6.3.0",
-        "react": ">=16.8.0"
-      }
-    },
-    "node_modules/@dnd-kit/utilities": {
-      "version": "3.2.2",
-      "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
-      "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
-      "license": "MIT",
-      "dependencies": {
-        "tslib": "^2.0.0"
-      },
-      "peerDependencies": {
-        "react": ">=16.8.0"
-      }
-    },
     "node_modules/@dotenvx/dotenvx": {
       "version": "1.51.4",
       "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.51.4.tgz",

+ 0 - 3
frontend/package.json

@@ -17,9 +17,6 @@
     "test:e2e:ui": "playwright test --ui"
   },
   "dependencies": {
-    "@dnd-kit/core": "^6.3.1",
-    "@dnd-kit/sortable": "^10.0.0",
-    "@dnd-kit/utilities": "^3.2.2",
     "@fontsource/noto-sans": "^5.2.10",
     "@fontsource/plus-jakarta-sans": "^5.2.8",
     "@radix-ui/react-accordion": "^1.2.12",

+ 0 - 2
frontend/src/App.tsx

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

+ 1171 - 1376
frontend/src/components/NowPlayingBar.tsx

@@ -1,1376 +1,1171 @@
-import { useState, useEffect, useRef, useCallback } from 'react'
-import { toast } from 'sonner'
-import { Button } from '@/components/ui/button'
-import { Progress } from '@/components/ui/progress'
-import { Input } from '@/components/ui/input'
-import {
-  Dialog,
-  DialogContent,
-  DialogDescription,
-  DialogHeader,
-  DialogTitle,
-} from '@/components/ui/dialog'
-import { apiClient } from '@/lib/apiClient'
-import { useStatusStore } from '@/stores/useStatusStore'
-import type { StatusData } from '@/stores/useStatusStore'
-import {
-  DndContext,
-  closestCenter,
-  KeyboardSensor,
-  PointerSensor,
-  useSensor,
-  useSensors,
-} from '@dnd-kit/core'
-import type { DragEndEvent } from '@dnd-kit/core'
-import {
-  SortableContext,
-  sortableKeyboardCoordinates,
-  useSortable,
-  verticalListSortingStrategy,
-} from '@dnd-kit/sortable'
-import { CSS } from '@dnd-kit/utilities'
-
-type Coordinate = [number, number]
-
-function formatTime(seconds: number): string {
-  if (!seconds || seconds < 0) return '--:--'
-  const mins = Math.floor(seconds / 60)
-  const secs = Math.floor(seconds % 60)
-  return `${mins}:${secs.toString().padStart(2, '0')}`
-}
-
-function formatPatternName(path: string | null): string {
-  if (!path) return 'Unknown'
-  // Extract filename without extension and path
-  const name = path.split('/').pop()?.replace('.thr', '') || path
-  return name
-}
-
-// Sortable queue item component for drag-and-drop (upcoming patterns only)
-interface SortableQueueItemProps {
-  id: string
-  file: string
-  index: number
-  previewUrl: string | null
-  isFirst: boolean
-  isLast: boolean
-  onMoveToTop: () => void
-  onMoveToBottom: () => void
-  requestPreview: (file: string) => void
-}
-
-function SortableQueueItem({
-  id,
-  file,
-  index,
-  previewUrl,
-  isFirst,
-  isLast,
-  onMoveToTop,
-  onMoveToBottom,
-  requestPreview,
-}: SortableQueueItemProps) {
-  const {
-    attributes,
-    listeners,
-    setNodeRef,
-    transform,
-    transition,
-    isDragging,
-  } = useSortable({ id })
-
-  const previewContainerRef = useRef<HTMLDivElement>(null)
-  const hasRequestedRef = useRef(false)
-
-  // Lazy load preview when item becomes visible
-  useEffect(() => {
-    if (!previewContainerRef.current || previewUrl || hasRequestedRef.current) return
-
-    const observer = new IntersectionObserver(
-      (entries) => {
-        entries.forEach((entry) => {
-          if (entry.isIntersecting && !hasRequestedRef.current) {
-            hasRequestedRef.current = true
-            requestPreview(file)
-            observer.disconnect()
-          }
-        })
-      },
-      { rootMargin: '50px' }
-    )
-
-    observer.observe(previewContainerRef.current)
-
-    return () => observer.disconnect()
-  }, [file, previewUrl, requestPreview])
-
-  const style = {
-    transform: CSS.Transform.toString(transform),
-    transition,
-    opacity: isDragging ? 0.5 : 1,
-    zIndex: isDragging ? 1000 : 'auto',
-  }
-
-  return (
-    <div
-      ref={setNodeRef}
-      style={style}
-      className={`group flex items-center gap-2 p-2 rounded-lg transition-colors hover:bg-muted/50 ${isDragging ? 'shadow-lg bg-background' : ''}`}
-    >
-      {/* Drag handle */}
-      <div
-        {...attributes}
-        {...listeners}
-        className="w-6 flex items-center justify-center shrink-0 cursor-grab active:cursor-grabbing touch-none"
-      >
-        <span className="material-icons-outlined text-muted-foreground text-sm">drag_indicator</span>
-      </div>
-
-      {/* Preview thumbnail */}
-      <div ref={previewContainerRef} className="w-28 h-28 rounded-full overflow-hidden bg-muted border shrink-0">
-        {previewUrl ? (
-          <img
-            src={previewUrl}
-            alt=""
-            loading="lazy"
-            className="w-full h-full object-cover pattern-preview"
-          />
-        ) : (
-          <div className="w-full h-full flex items-center justify-center">
-            <span className="material-icons-outlined text-muted-foreground text-4xl">image</span>
-          </div>
-        )}
-      </div>
-
-      {/* Pattern name */}
-      <div className="flex-1 min-w-0">
-        <p className="text-sm truncate">{formatPatternName(file)}</p>
-        <p className="text-xs text-muted-foreground">#{index + 1}</p>
-      </div>
-
-      {/* Move to top/bottom buttons - always visible on mobile, hover on desktop */}
-      <div className="flex flex-col gap-1 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity shrink-0">
-        <button
-          onClick={onMoveToTop}
-          disabled={isFirst}
-          className="p-1 rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"
-          title="Move to top"
-        >
-          <span className="material-icons-outlined text-sm">vertical_align_top</span>
-        </button>
-        <button
-          onClick={onMoveToBottom}
-          disabled={isLast}
-          className="p-1 rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"
-          title="Move to bottom"
-        >
-          <span className="material-icons-outlined text-sm">vertical_align_bottom</span>
-        </button>
-      </div>
-    </div>
-  )
-}
-
-interface NowPlayingBarProps {
-  isLogsOpen?: boolean
-  logsDrawerHeight?: number
-  isVisible: boolean
-  openExpanded?: boolean
-  onClose: () => void
-}
-
-export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVisible, openExpanded = false, onClose }: NowPlayingBarProps) {
-  const status: StatusData | null = useStatusStore((s) => s.status)
-  const [previewUrl, setPreviewUrl] = useState<string | null>(null)
-
-  // Expanded state for slide-up view
-  const [isExpanded, setIsExpanded] = useState(false)
-
-  // Swipe gesture handling
-  const touchStartY = useRef<number | null>(null)
-  const barRef = useRef<HTMLDivElement>(null)
-
-  const handleTouchStart = (e: React.TouchEvent) => {
-    touchStartY.current = e.touches[0].clientY
-  }
-  const handleTouchEnd = (e: React.TouchEvent) => {
-    if (touchStartY.current === null) return
-    const touchEndY = e.changedTouches[0].clientY
-    const deltaY = touchEndY - touchStartY.current
-
-    if (deltaY > 50) {
-      // Swipe down
-      if (isExpanded) {
-        setIsExpanded(false) // Collapse to mini
-      } else {
-        onClose() // Hide the bar
-      }
-    } else if (deltaY < -50 && isPlaying) {
-      // Swipe up - expand (only if playing)
-      setIsExpanded(true)
-    }
-    touchStartY.current = null
-  }
-
-  // Prevent background scroll when Now Playing bar is visible
-  useEffect(() => {
-    if (isVisible) {
-      // Lock body scroll when bar is visible on mobile
-      document.body.style.overflow = 'hidden'
-      return () => {
-        document.body.style.overflow = ''
-      }
-    }
-  }, [isVisible])
-
-  // Use native event listener for touchmove to prevent background scroll on the bar itself
-  useEffect(() => {
-    const bar = barRef.current
-    if (!bar) return
-
-    const handleTouchMove = (e: TouchEvent) => {
-      // Only prevent default if not scrolling inside a scrollable element
-      const target = e.target as HTMLElement
-      const scrollableParent = target.closest('[data-scrollable]')
-      if (!scrollableParent) {
-        e.preventDefault()
-      }
-    }
-
-    bar.addEventListener('touchmove', handleTouchMove, { passive: false })
-    return () => {
-      bar.removeEventListener('touchmove', handleTouchMove)
-    }
-  }, [])
-
-  // Open in expanded mode when openExpanded prop changes to true
-  useEffect(() => {
-    if (openExpanded && isVisible) {
-      setIsExpanded(true)
-    }
-  }, [openExpanded, isVisible])
-
-  // Listen for playback-started event from Layout (more reliable than prop)
-  useEffect(() => {
-    const handlePlaybackStarted = () => {
-      setIsExpanded(true)
-    }
-    window.addEventListener('playback-started', handlePlaybackStarted)
-    return () => window.removeEventListener('playback-started', handlePlaybackStarted)
-  }, [])
-
-  // Auto-collapse when nothing is playing (with delay to avoid race condition)
-  // Include pause_time_remaining to keep UI active during countdown between patterns
-  const isPlaying = status?.is_running || status?.is_paused || (status?.pause_time_remaining ?? 0) > 0
-  const collapseTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
-  useEffect(() => {
-    // Clear any pending collapse
-    if (collapseTimeoutRef.current) {
-      clearTimeout(collapseTimeoutRef.current)
-      collapseTimeoutRef.current = null
-    }
-
-    if (!isPlaying && isExpanded) {
-      // Delay collapse to avoid race condition with playback-started
-      collapseTimeoutRef.current = setTimeout(() => {
-        setIsExpanded(false)
-      }, 500)
-    }
-
-    return () => {
-      if (collapseTimeoutRef.current) {
-        clearTimeout(collapseTimeoutRef.current)
-      }
-    }
-  }, [isPlaying, isExpanded])
-
-  const [coordinates, setCoordinates] = useState<Coordinate[]>([])
-  const canvasRef = useRef<HTMLCanvasElement>(null)
-  const offscreenCanvasRef = useRef<HTMLCanvasElement | null>(null)
-  const lastDrawnIndexRef = useRef<number>(-1)
-  const lastFileRef = useRef<string | null>(null)
-  const lastThemeRef = useRef<boolean | null>(null)
-
-  // Smooth animation refs
-  const animationFrameRef = useRef<number | null>(null)
-  const lastProgressRef = useRef<number>(0)
-  const lastProgressTimeRef = useRef<number>(0)
-  const smoothProgressRef = useRef<number>(0)
-
-  // Fetch preview images for current and next patterns
-  const [nextPreviewUrl, setNextPreviewUrl] = useState<string | null>(null)
-  const lastFetchedFilesRef = useRef<string>('')
-
-  useEffect(() => {
-    // Don't fetch if not visible
-    if (!isVisible) return
-
-    const currentFile = status?.current_file
-    const nextFile = status?.playlist?.next_file
-
-    // Build list of files to fetch
-    const filesToFetch = [currentFile, nextFile].filter(Boolean) as string[]
-    const fetchKey = filesToFetch.join('|')
-
-    // Skip if we already fetched these exact files
-    if (fetchKey === lastFetchedFilesRef.current) return
-    lastFetchedFilesRef.current = fetchKey
-
-    if (filesToFetch.length > 0) {
-      apiClient.post<Record<string, { image_data?: string }>>('/preview_thr_batch', { file_names: filesToFetch })
-        .then((data) => {
-          if (currentFile && data[currentFile]?.image_data) {
-            setPreviewUrl(data[currentFile].image_data)
-          } else {
-            setPreviewUrl(null)
-          }
-          if (nextFile && data[nextFile]?.image_data) {
-            setNextPreviewUrl(data[nextFile].image_data)
-          } else {
-            setNextPreviewUrl(null)
-          }
-        })
-        .catch(() => {
-          setPreviewUrl(null)
-          setNextPreviewUrl(null)
-        })
-    } else {
-      setPreviewUrl(null)
-      setNextPreviewUrl(null)
-    }
-  }, [isVisible, status?.current_file, status?.playlist?.next_file])
-
-  // Canvas drawing functions for real-time preview
-  const polarToCartesian = useCallback((theta: number, rho: number, size: number) => {
-    const centerX = size / 2
-    const centerY = size / 2
-    const radius = (size / 2) * 0.9 * rho
-    const x = centerX + radius * Math.cos(theta)
-    const y = centerY + radius * Math.sin(theta)
-    return { x, y }
-  }, [])
-
-  const getThemeColors = useCallback(() => {
-    const isDark = document.documentElement.classList.contains('dark')
-    return {
-      isDark,
-      bgOuter: isDark ? '#1a1a1a' : '#f5f5f5',
-      bgInner: isDark ? '#262626' : '#ffffff',
-      borderColor: isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(128, 128, 128, 0.3)',
-      lineColor: isDark ? '#e5e5e5' : '#333333',
-      markerBorder: isDark ? '#333333' : '#ffffff',
-    }
-  }, [])
-
-  const initOffscreenCanvas = useCallback((size: number, coords: Coordinate[]) => {
-    const colors = getThemeColors()
-
-    if (!offscreenCanvasRef.current) {
-      offscreenCanvasRef.current = document.createElement('canvas')
-    }
-
-    const offscreen = offscreenCanvasRef.current
-    offscreen.width = size
-    offscreen.height = size
-
-    const ctx = offscreen.getContext('2d')
-    if (!ctx) return
-
-    ctx.fillStyle = colors.bgOuter
-    ctx.fillRect(0, 0, size, size)
-
-    ctx.beginPath()
-    ctx.arc(size / 2, size / 2, (size / 2) * 0.95, 0, Math.PI * 2)
-    ctx.fillStyle = colors.bgInner
-    ctx.fill()
-    ctx.strokeStyle = colors.borderColor
-    ctx.lineWidth = 1
-    ctx.stroke()
-
-    ctx.strokeStyle = colors.lineColor
-    ctx.lineWidth = 1.5
-    ctx.lineCap = 'round'
-    ctx.lineJoin = 'round'
-
-    if (coords.length > 0) {
-      const firstPoint = polarToCartesian(coords[0][0], coords[0][1], size)
-      ctx.beginPath()
-      ctx.moveTo(firstPoint.x, firstPoint.y)
-      ctx.stroke()
-    }
-
-    lastDrawnIndexRef.current = 0
-    lastThemeRef.current = colors.isDark
-  }, [getThemeColors, polarToCartesian])
-
-  const drawPattern = useCallback((ctx: CanvasRenderingContext2D, coords: Coordinate[], smoothIndex: number, forceRedraw = false) => {
-    const canvas = ctx.canvas
-    const size = canvas.width
-    const colors = getThemeColors()
-
-    // Apply 16 coordinate offset for physical latency
-    const adjustedSmoothIndex = Math.max(0, smoothIndex - 16)
-    const adjustedIndex = Math.floor(adjustedSmoothIndex)
-
-    const needsReinit = forceRedraw ||
-      !offscreenCanvasRef.current ||
-      lastThemeRef.current !== colors.isDark ||
-      adjustedIndex < lastDrawnIndexRef.current
-
-    if (needsReinit) {
-      initOffscreenCanvas(size, coords)
-    }
-
-    const offscreen = offscreenCanvasRef.current
-    if (!offscreen) return
-
-    const offCtx = offscreen.getContext('2d')
-    if (!offCtx) return
-
-    if (coords.length > 0 && adjustedIndex > lastDrawnIndexRef.current) {
-      offCtx.strokeStyle = colors.lineColor
-      offCtx.lineWidth = 1.5
-      offCtx.lineCap = 'round'
-      offCtx.lineJoin = 'round'
-
-      offCtx.beginPath()
-      const startPoint = polarToCartesian(
-        coords[lastDrawnIndexRef.current][0],
-        coords[lastDrawnIndexRef.current][1],
-        size
-      )
-      offCtx.moveTo(startPoint.x, startPoint.y)
-
-      for (let i = lastDrawnIndexRef.current + 1; i <= adjustedIndex && i < coords.length; i++) {
-        const point = polarToCartesian(coords[i][0], coords[i][1], size)
-        offCtx.lineTo(point.x, point.y)
-      }
-      offCtx.stroke()
-
-      lastDrawnIndexRef.current = adjustedIndex
-    }
-
-    ctx.drawImage(offscreen, 0, 0)
-
-    // Draw current position marker with smooth interpolation between coordinates
-    if (coords.length > 0 && adjustedIndex < coords.length - 1) {
-      const fraction = adjustedSmoothIndex - adjustedIndex
-      const currentCoord = coords[adjustedIndex]
-      const nextCoord = coords[Math.min(adjustedIndex + 1, coords.length - 1)]
-
-      // Interpolate theta and rho
-      const interpTheta = currentCoord[0] + (nextCoord[0] - currentCoord[0]) * fraction
-      const interpRho = currentCoord[1] + (nextCoord[1] - currentCoord[1]) * fraction
-
-      const currentPoint = polarToCartesian(interpTheta, interpRho, size)
-      ctx.beginPath()
-      ctx.arc(currentPoint.x, currentPoint.y, 8, 0, Math.PI * 2)
-      ctx.fillStyle = '#0b80ee'
-      ctx.fill()
-      ctx.strokeStyle = colors.markerBorder
-      ctx.lineWidth = 2
-      ctx.stroke()
-    } else if (coords.length > 0 && adjustedIndex < coords.length) {
-      // At the last coordinate, just draw without interpolation
-      const currentPoint = polarToCartesian(coords[adjustedIndex][0], coords[adjustedIndex][1], size)
-      ctx.beginPath()
-      ctx.arc(currentPoint.x, currentPoint.y, 8, 0, Math.PI * 2)
-      ctx.fillStyle = '#0b80ee'
-      ctx.fill()
-      ctx.strokeStyle = colors.markerBorder
-      ctx.lineWidth = 2
-      ctx.stroke()
-    }
-  }, [getThemeColors, initOffscreenCanvas, polarToCartesian])
-
-  // Fetch coordinates when file changes or fullscreen opens
-  useEffect(() => {
-    const currentFile = status?.current_file
-    if (!currentFile) return
-
-    // Only fetch if file changed or we don't have coordinates yet
-    const needsFetch = currentFile !== lastFileRef.current || coordinates.length === 0
-
-    if (!needsFetch) return
-
-    lastFileRef.current = currentFile
-    lastDrawnIndexRef.current = -1
-
-    apiClient.post<{ coordinates?: Coordinate[] }>('/get_theta_rho_coordinates', { file_name: currentFile })
-      .then((data) => {
-        if (data.coordinates && Array.isArray(data.coordinates)) {
-          setCoordinates(data.coordinates)
-        }
-      })
-      .catch((err) => {
-        console.error('Failed to fetch coordinates:', err)
-        setCoordinates([])
-      })
-  }, [status?.current_file, coordinates.length])
-
-  // Get target index from progress percentage
-  const getTargetIndex = useCallback((coords: Coordinate[]): number => {
-    if (coords.length === 0) return 0
-    const progressPercent = status?.progress?.percentage || 0
-    return (progressPercent / 100) * coords.length
-  }, [status?.progress?.percentage])
-
-  // Track progress updates for smooth interpolation
-  useEffect(() => {
-    const currentProgress = status?.progress?.percentage || 0
-    if (currentProgress !== lastProgressRef.current) {
-      lastProgressRef.current = currentProgress
-      lastProgressTimeRef.current = performance.now()
-    }
-  }, [status?.progress?.percentage])
-
-  // Smooth animation loop
-  useEffect(() => {
-    if (!isExpanded || coordinates.length === 0) return
-
-    const isPaused = status?.is_paused || false
-    const coordsPerSecond = 4.2
-
-    const animate = () => {
-      if (!canvasRef.current) return
-
-      const ctx = canvasRef.current.getContext('2d')
-      if (!ctx) return
-
-      const targetIndex = getTargetIndex(coordinates)
-      const now = performance.now()
-      const timeSinceUpdate = (now - lastProgressTimeRef.current) / 1000
-
-      let smoothIndex: number
-      if (isPaused) {
-        // When paused, just use the target index directly
-        smoothIndex = targetIndex
-      } else {
-        // Interpolate: start from where we were at last update, advance based on time
-        const baseIndex = (lastProgressRef.current / 100) * coordinates.length
-        smoothIndex = baseIndex + (timeSinceUpdate * coordsPerSecond)
-        // Don't overshoot the target too much
-        smoothIndex = Math.min(smoothIndex, targetIndex + 2)
-      }
-
-      smoothProgressRef.current = smoothIndex
-      drawPattern(ctx, coordinates, smoothIndex)
-
-      animationFrameRef.current = requestAnimationFrame(animate)
-    }
-
-    // Initial draw with force redraw
-    const timer = setTimeout(() => {
-      if (!canvasRef.current) return
-      const ctx = canvasRef.current.getContext('2d')
-      if (!ctx) return
-
-      lastDrawnIndexRef.current = -1
-      offscreenCanvasRef.current = null
-      smoothProgressRef.current = getTargetIndex(coordinates)
-      lastProgressTimeRef.current = performance.now()
-
-      drawPattern(ctx, coordinates, smoothProgressRef.current, true)
-
-      // Start animation loop
-      animationFrameRef.current = requestAnimationFrame(animate)
-    }, 50)
-
-    return () => {
-      clearTimeout(timer)
-      if (animationFrameRef.current) {
-        cancelAnimationFrame(animationFrameRef.current)
-      }
-    }
-  }, [isExpanded, coordinates, status?.is_paused, drawPattern, getTargetIndex])
-
-  const handlePause = async () => {
-    try {
-      const endpoint = status?.is_paused ? '/resume_execution' : '/pause_execution'
-      await apiClient.post(endpoint)
-      toast.success(status?.is_paused ? 'Resumed' : 'Paused')
-    } catch (error) {
-      // Extract error detail from backend response (format: "HTTP 400: {"detail":"message"}")
-      let errorMessage = 'Failed to toggle pause'
-      if (error instanceof Error) {
-        try {
-          const jsonMatch = error.message.match(/\{.*\}/)
-          if (jsonMatch) {
-            const parsed = JSON.parse(jsonMatch[0])
-            if (parsed.detail) {
-              errorMessage = parsed.detail
-            }
-          }
-        } catch {
-          // Keep default message if parsing fails
-        }
-      }
-      toast.error(errorMessage)
-    }
-  }
-
-  const handleStop = async () => {
-    try {
-      await apiClient.post('/stop_execution')
-      toast.success('Stopped')
-    } catch {
-      // Normal stop failed, try force stop
-      try {
-        await apiClient.post('/force_stop')
-        toast.success('Force stopped')
-      } catch {
-        toast.error('Failed to stop')
-      }
-    }
-  }
-
-  const handleSkip = async () => {
-    try {
-      await apiClient.post('/skip_pattern')
-      toast.success('Skipping to next pattern')
-    } catch {
-      toast.error('Failed to skip')
-    }
-  }
-
-  const [speedInput, setSpeedInput] = useState('')
-  const [showQueue, setShowQueue] = useState(false)
-  const [queuePreviews, setQueuePreviews] = useState<Record<string, string>>({})
-
-  // Queue dialog swipe-to-dismiss
-  const queueTouchStartY = useRef<number | null>(null)
-  const queueDialogRef = useRef<HTMLDivElement>(null)
-
-  const handleQueueTouchStart = (e: React.TouchEvent) => {
-    queueTouchStartY.current = e.touches[0].clientY
-  }
-
-  const handleQueueTouchEnd = (e: React.TouchEvent) => {
-    if (queueTouchStartY.current === null) return
-    const touchEndY = e.changedTouches[0].clientY
-    const deltaY = touchEndY - queueTouchStartY.current
-
-    // Swipe down to dismiss (only if at top of scroll or large swipe)
-    if (deltaY > 80) {
-      const scrollContainer = queueDialogRef.current?.querySelector('[data-scrollable]') as HTMLElement
-      const isAtTop = !scrollContainer || scrollContainer.scrollTop <= 0
-      if (isAtTop) {
-        setShowQueue(false)
-      }
-    }
-    queueTouchStartY.current = null
-  }
-
-  // Optimistic queue state for smooth drag-and-drop
-  const [optimisticQueue, setOptimisticQueue] = useState<string[] | null>(null)
-  const optimisticTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
-
-  // Sync optimistic queue with server state after a delay
-  // This allows the optimistic update to "stick" while the server catches up
-  useEffect(() => {
-    if (optimisticQueue && status?.playlist?.files) {
-      // Clear any pending timeout
-      if (optimisticTimeoutRef.current) {
-        clearTimeout(optimisticTimeoutRef.current)
-      }
-      // After server confirms (via WebSocket), clear optimistic state
-      // We check if server state matches our optimistic state
-      const serverOrder = status.playlist.files.join(',')
-      const optimisticOrder = optimisticQueue.join(',')
-      if (serverOrder === optimisticOrder) {
-        // Server caught up, clear optimistic state
-        setOptimisticQueue(null)
-      } else {
-        // Give server time to catch up, then accept server state
-        optimisticTimeoutRef.current = setTimeout(() => {
-          setOptimisticQueue(null)
-        }, 2000)
-      }
-    }
-    return () => {
-      if (optimisticTimeoutRef.current) {
-        clearTimeout(optimisticTimeoutRef.current)
-      }
-    }
-  }, [status?.playlist?.files, optimisticQueue])
-
-  // Use optimistic queue if available, otherwise use server state
-  const displayQueue = optimisticQueue || status?.playlist?.files || []
-
-  // Drag and drop sensors
-  const sensors = useSensors(
-    useSensor(PointerSensor, {
-      activationConstraint: {
-        distance: 8, // Require 8px movement before starting drag
-      },
-    }),
-    useSensor(KeyboardSensor, {
-      coordinateGetter: sortableKeyboardCoordinates,
-    })
-  )
-
-  const handleSpeedSubmit = async () => {
-    const speed = parseInt(speedInput)
-    if (isNaN(speed) || speed < 10 || speed > 6000) {
-      toast.error('Speed must be between 10 and 6000 mm/s')
-      return
-    }
-    try {
-      await apiClient.post('/set_speed', { speed })
-      setSpeedInput('')
-      toast.success(`Speed set to ${speed} mm/s`)
-    } catch {
-      toast.error('Failed to set speed')
-    }
-  }
-
-  // Track which files we've already requested previews for
-  const requestedPreviewsRef = useRef<Set<string>>(new Set())
-  const pendingQueuePreviewsRef = useRef<Set<string>>(new Set())
-  const batchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
-
-  // Batched queue preview fetching - collects requests and fetches in batches
-  const requestQueuePreview = useCallback((file: string) => {
-    // Skip if already loaded or pending
-    if (queuePreviews[file] || requestedPreviewsRef.current.has(file) || pendingQueuePreviewsRef.current.has(file)) return
-
-    pendingQueuePreviewsRef.current.add(file)
-
-    // Debounce batch fetch
-    if (batchTimeoutRef.current) clearTimeout(batchTimeoutRef.current)
-    batchTimeoutRef.current = setTimeout(async () => {
-      const filesToFetch = Array.from(pendingQueuePreviewsRef.current)
-      pendingQueuePreviewsRef.current.clear()
-      if (filesToFetch.length === 0) return
-
-      // Mark as requested
-      filesToFetch.forEach(f => requestedPreviewsRef.current.add(f))
-
-      try {
-        const data = await apiClient.post<Record<string, { image_data?: string }>>('/preview_thr_batch', { file_names: filesToFetch })
-        const newPreviews: Record<string, string> = {}
-        for (const [file, result] of Object.entries(data)) {
-          if (result.image_data) {
-            newPreviews[file] = result.image_data
-          }
-        }
-        if (Object.keys(newPreviews).length > 0) {
-          setQueuePreviews(prev => ({ ...prev, ...newPreviews }))
-        }
-      } catch (err) {
-        console.error('Failed to fetch queue previews:', err)
-      }
-    }, 100)
-  }, [queuePreviews])
-
-  // Helper to reorder array (move item from one index to another)
-  const reorderArray = (arr: string[], fromIndex: number, toIndex: number): string[] => {
-    const result = [...arr]
-    const [removed] = result.splice(fromIndex, 1)
-    result.splice(toIndex, 0, removed)
-    return result
-  }
-
-  // Handle drag end for reordering queue
-  // Since playlist now contains only main patterns, indices map directly
-  const handleDragEnd = async (event: DragEndEvent) => {
-    const { active, over } = event
-
-    if (!over || active.id === over.id || !status?.playlist?.files) return
-
-    // Extract indices from IDs
-    const fromIndex = parseInt(active.id.toString().replace('queue-item-', ''))
-    const toIndex = parseInt(over.id.toString().replace('queue-item-', ''))
-
-    if (isNaN(fromIndex) || isNaN(toIndex)) return
-
-    const currentIndex = status.playlist.current_index
-
-    // Can't move patterns that have already played
-    if (fromIndex < currentIndex) {
-      toast.error("Can't move completed pattern")
-      return
-    }
-    if (toIndex < currentIndex) {
-      toast.error("Can't move to completed position")
-      return
-    }
-
-    // Optimistically update the queue immediately
-    const currentQueue = optimisticQueue || status.playlist.files
-    const newQueue = reorderArray(currentQueue, fromIndex, toIndex)
-    setOptimisticQueue(newQueue)
-
-    try {
-      await apiClient.post('/reorder_playlist', {
-        from_index: fromIndex,
-        to_index: toIndex
-      })
-    } catch {
-      // Revert optimistic update on failure
-      setOptimisticQueue(null)
-      toast.error('Failed to reorder')
-    }
-  }
-
-  // Helper to move queue item to a specific position
-  const moveToPosition = async (fromIndex: number, toIndex: number) => {
-    if (fromIndex === toIndex || !status?.playlist?.files) return
-
-    // Optimistically update the queue immediately
-    const currentQueue = optimisticQueue || status.playlist.files
-    const newQueue = reorderArray(currentQueue, fromIndex, toIndex)
-    setOptimisticQueue(newQueue)
-
-    try {
-      await apiClient.post('/reorder_playlist', {
-        from_index: fromIndex,
-        to_index: toIndex
-      })
-    } catch {
-      // Revert optimistic update on failure
-      setOptimisticQueue(null)
-      toast.error('Failed to reorder')
-    }
-  }
-
-  // Don't render if not visible
-  if (!isVisible) {
-    return null
-  }
-
-  const patternName = formatPatternName(status?.current_file ?? null)
-  const progressPercent = status?.progress?.percentage || 0
-  const tqdmRemainingTime = status?.progress?.remaining_time || 0
-  const elapsedTime = status?.progress?.elapsed_time || 0
-
-  // Use historical time if available, otherwise fall back to tqdm estimate
-  const historicalTime = status?.progress?.last_completed_time?.actual_time_seconds
-  const remainingTime = historicalTime
-    ? Math.max(0, historicalTime - elapsedTime)
-    : tqdmRemainingTime
-  const usingHistoricalEta = !!historicalTime
-
-  // Detect waiting state between patterns
-  const isWaiting = (status?.pause_time_remaining ?? 0) > 0
-  const waitTimeRemaining = status?.pause_time_remaining ?? 0
-  const originalWaitTime = status?.original_pause_time ?? 0
-  const waitProgress = originalWaitTime > 0 ? ((originalWaitTime - waitTimeRemaining) / originalWaitTime) * 100 : 0
-
-  return (
-    <>
-      {/* Backdrop when expanded */}
-      {isExpanded && (
-        <div
-          className="fixed inset-0 bg-black/30 z-30"
-          onClick={() => setIsExpanded(false)}
-        />
-      )}
-
-      {/* Now Playing Bar - slides up to full height on mobile, 50vh on desktop when expanded */}
-      <div
-        ref={barRef}
-        className="fixed left-0 right-0 z-40 bg-background border-t shadow-lg transition-all duration-300"
-        style={{
-          bottom: isLogsOpen
-            ? `calc(${logsDrawerHeight}px + 4rem + env(safe-area-inset-bottom, 0px))`
-            : 'calc(4rem + env(safe-area-inset-bottom, 0px))'
-        }}
-        data-now-playing-bar={isExpanded ? 'expanded' : 'collapsed'}
-        onTouchStart={handleTouchStart}
-        onTouchEnd={handleTouchEnd}
-      >
-        {/* Max-width container to match page layout */}
-        <div className="h-full max-w-5xl mx-auto relative">
-          {/* Swipe indicator - only on mobile */}
-          <div className="md:hidden flex justify-center pt-2 pb-1">
-            <div className="w-10 h-1 bg-muted-foreground/30 rounded-full" />
-          </div>
-
-          {/* Header with action buttons - add safe area when expanded for Dynamic Island */}
-          <div className={`absolute right-3 sm:right-4 flex items-center gap-1 z-10 ${isExpanded ? 'top-3 mt-safe' : 'top-3'}`}>
-          {/* Queue button - mobile only, when playlist exists */}
-          {isPlaying && status?.playlist && (
-            <Button
-              variant="ghost"
-              size="icon"
-              className="md:hidden h-8 w-8"
-              onClick={() => setShowQueue(true)}
-              title="View queue"
-            >
-              <span className="material-icons-outlined text-lg">queue_music</span>
-            </Button>
-          )}
-          {isPlaying && (
-            <Button
-              variant="ghost"
-              size="icon"
-              className="h-8 w-8"
-              onClick={() => setIsExpanded(!isExpanded)}
-              title={isExpanded ? 'Collapse' : 'Expand'}
-            >
-              <span className="material-icons-outlined text-lg">
-                {isExpanded ? 'expand_more' : 'expand_less'}
-              </span>
-            </Button>
-          )}
-          <Button
-            variant="ghost"
-            size="icon"
-            className="h-8 w-8"
-            onClick={onClose}
-            title="Close"
-          >
-            <span className="material-icons-outlined text-lg">close</span>
-          </Button>
-        </div>
-
-        {/* Content container */}
-        <div className="h-full flex flex-col">
-          {/* Collapsed view - Mini Bar */}
-          {!isExpanded && (
-            <div className="flex-1 flex flex-col">
-              {/* Main row with preview and controls */}
-              <div className="flex-1 flex items-center gap-6 px-6 py-4">
-                {/* Current Pattern Preview - Rounded (click to expand) */}
-                <div
-                  className="w-48 h-48 rounded-full overflow-hidden bg-muted shrink-0 border-2 cursor-pointer hover:border-primary transition-colors"
-                  onClick={() => isPlaying && setIsExpanded(true)}
-                  title={isPlaying ? 'Click to expand' : undefined}
-                >
-                  {previewUrl && isPlaying ? (
-                    <img
-                      src={previewUrl}
-                      alt={patternName}
-                      className="w-full h-full object-cover pattern-preview"
-                    />
-                  ) : (
-                    <div className="w-full h-full flex items-center justify-center">
-                      <span className="material-icons-outlined text-muted-foreground text-4xl">
-                        {isPlaying ? 'image' : 'hourglass_empty'}
-                      </span>
-                    </div>
-                  )}
-                </div>
-
-                {/* Main Content Area */}
-                {isPlaying && status ? (
-                  <>
-                    <div className="flex-1 min-w-0 flex flex-col justify-center gap-2 py-2">
-                      {/* Title Row */}
-                      <div className="flex items-center gap-3 pr-12 md:pr-16">
-                        <div className="flex-1 min-w-0">
-                          {isWaiting ? (
-                            <>
-                              <p className="text-sm md:text-base font-semibold text-muted-foreground">
-                                Waiting for next pattern...
-                              </p>
-                              {status.playlist?.next_file && (
-                                <p className="text-xs text-muted-foreground">
-                                  Up next: {formatPatternName(status.playlist.next_file)}
-                                </p>
-                              )}
-                            </>
-                          ) : (
-                            <>
-                              <p className="text-sm md:text-base font-semibold truncate">
-                                {patternName}
-                              </p>
-                              {status.playlist && (
-                                <p className="text-xs text-muted-foreground">
-                                  Pattern {status.playlist.current_index + 1} of {status.playlist.total_files}
-                                </p>
-                              )}
-                            </>
-                          )}
-                        </div>
-                      </div>
-
-                      {/* Progress Bar - Desktop only (inline, above controls) */}
-                      {isWaiting ? (
-                        <div className="hidden md:flex items-center gap-3">
-                          <span className="material-icons-outlined text-muted-foreground text-lg">hourglass_top</span>
-                          <Progress value={waitProgress} className="h-2 flex-1" />
-                          <span className="text-sm text-muted-foreground font-mono">{formatTime(waitTimeRemaining)}</span>
-                        </div>
-                      ) : (
-                        <div className="hidden md:flex items-center gap-3">
-                          <span className="text-sm text-muted-foreground w-12 font-mono">{formatTime(elapsedTime)}</span>
-                          <Progress value={progressPercent} className="h-2 flex-1" />
-                          <span
-                            className={`text-sm text-muted-foreground text-right font-mono flex items-center justify-end gap-1.5 shrink-0 ${usingHistoricalEta ? 'w-24' : 'w-14'}`}
-                            title={usingHistoricalEta ? 'ETA based on last completed run' : 'Estimated time remaining'}
-                          >
-                            {usingHistoricalEta && <span className="material-icons-outlined text-sm">history</span>}
-                            -{formatTime(remainingTime)}
-                          </span>
-                        </div>
-                      )}
-
-                      {/* Playback Controls - Centered */}
-                      <div className="flex items-center justify-center gap-3">
-                        <Button
-                          variant="secondary"
-                          size="icon"
-                          className="h-10 w-10 rounded-full"
-                          onClick={handleStop}
-                          title="Stop"
-                        >
-                          <span className="material-icons">stop</span>
-                        </Button>
-                        <Button
-                          variant="default"
-                          size="icon"
-                          className="h-12 w-12 rounded-full"
-                          onClick={handlePause}
-                        >
-                          <span className="material-icons text-xl">
-                            {status.is_paused ? 'play_arrow' : 'pause'}
-                          </span>
-                        </Button>
-                        {status.playlist && (
-                          <Button
-                            variant="secondary"
-                            size="icon"
-                            className="h-10 w-10 rounded-full"
-                            onClick={handleSkip}
-                            title="Skip to next"
-                          >
-                            <span className="material-icons">skip_next</span>
-                          </Button>
-                        )}
-                      </div>
-
-                      {/* Speed Control */}
-                      <div className="flex items-center justify-center gap-2">
-                        <span className="text-sm text-muted-foreground">Speed:</span>
-                        <Input
-                          type="number"
-                          placeholder={String(status.speed)}
-                          value={speedInput}
-                          onChange={(e) => setSpeedInput(e.target.value)}
-                          onKeyDown={(e) => e.key === 'Enter' && handleSpeedSubmit()}
-                          className="h-7 w-20 text-sm px-2"
-                        />
-                        <span className="text-sm text-muted-foreground">mm/s</span>
-                      </div>
-                    </div>
-
-                    {/* Next Pattern Preview - hidden on mobile */}
-                    {status.playlist?.next_file && (
-                      <div
-                        className="hidden md:flex shrink-0 flex-col items-center gap-1 mr-16 cursor-pointer hover:opacity-80 transition-opacity"
-                        onClick={() => setShowQueue(true)}
-                        title="View queue"
-                      >
-                        <p className="text-xs text-muted-foreground font-medium flex items-center gap-1">
-                          Up Next
-                          <span className="material-icons-outlined text-xs">queue_music</span>
-                        </p>
-                        <div className="w-24 h-24 rounded-full overflow-hidden bg-muted border-2">
-                          {nextPreviewUrl ? (
-                            <img
-                              src={nextPreviewUrl}
-                              alt="Next pattern"
-                              className="w-full h-full object-cover pattern-preview"
-                            />
-                          ) : (
-                            <div className="w-full h-full flex items-center justify-center">
-                              <span className="material-icons-outlined text-muted-foreground text-2xl">image</span>
-                            </div>
-                          )}
-                        </div>
-                        <p className="text-xs text-muted-foreground text-center max-w-24 truncate">
-                          {formatPatternName(status.playlist.next_file)}
-                        </p>
-                      </div>
-                    )}
-                  </>
-                ) : (
-                  <div className="flex-1 flex items-center">
-                    <p className="text-lg text-muted-foreground">Not playing</p>
-                  </div>
-                )}
-              </div>
-
-              {/* Progress Bar - Mobile only (full width at bottom) */}
-              {isPlaying && status && (
-                isWaiting ? (
-                  <div className="flex md:hidden items-center gap-3 px-6 pb-16">
-                    <span className="material-icons-outlined text-muted-foreground text-lg">hourglass_top</span>
-                    <Progress value={waitProgress} className="h-2 flex-1" />
-                    <span className="text-sm text-muted-foreground font-mono">{formatTime(waitTimeRemaining)}</span>
-                  </div>
-                ) : (
-                  <div className="flex md:hidden items-center gap-3 px-6 pb-16">
-                    <span className="text-sm text-muted-foreground w-12 font-mono">{formatTime(elapsedTime)}</span>
-                    <Progress value={progressPercent} className="h-2 flex-1" />
-                    <span className={`text-sm text-muted-foreground text-right font-mono flex items-center justify-end gap-1.5 shrink-0 ${usingHistoricalEta ? 'w-24' : 'w-14'}`}>
-                      {usingHistoricalEta && <span className="material-icons-outlined text-sm">history</span>}
-                      -{formatTime(remainingTime)}
-                    </span>
-                  </div>
-                )
-              )}
-            </div>
-          )}
-
-          {/* Expanded view - Real-time canvas preview */}
-          {isExpanded && isPlaying && (
-            <div className="flex-1 flex flex-col md:items-center md:justify-center px-4 py-4 md:py-8 pt-safe overflow-hidden">
-              <div className="w-full max-w-5xl mx-auto flex flex-col md:flex-row md:items-center gap-3 md:gap-6">
-                {/* Canvas - full width on mobile (click to collapse) */}
-                <div
-                  className="flex-1 flex items-center justify-center cursor-pointer"
-                  onClick={() => setIsExpanded(false)}
-                  title="Click to collapse"
-                >
-                  <canvas
-                    ref={canvasRef}
-                    width={600}
-                    height={600}
-                    className="rounded-full border-2 hover:border-primary transition-colors w-[40vh] h-[40vh] max-w-[300px] max-h-[300px] md:w-[42vh] md:h-[42vh] md:max-w-[500px] md:max-h-[500px]"
-                  />
-                </div>
-
-                {/* Controls */}
-                <div className="md:w-80 shrink-0 flex flex-col justify-start md:justify-center gap-2 md:gap-4">
-                {/* Pattern Info */}
-                <div className="flex items-center justify-center gap-3">
-                  {/* Current pattern preview */}
-                  <div className="w-10 h-10 md:w-12 md:h-12 rounded-full overflow-hidden bg-muted border shrink-0">
-                    {previewUrl ? (
-                      <img
-                        src={previewUrl}
-                        alt={patternName}
-                        className="w-full h-full object-cover pattern-preview"
-                      />
-                    ) : (
-                      <div className="w-full h-full flex items-center justify-center">
-                        <span className="material-icons-outlined text-muted-foreground text-sm">image</span>
-                      </div>
-                    )}
-                  </div>
-                  <div className="text-left min-w-0">
-                    {isWaiting ? (
-                      <>
-                        <h2 className="text-lg md:text-xl font-semibold text-muted-foreground">
-                          Waiting for next pattern...
-                        </h2>
-                        {status?.playlist?.next_file && (
-                          <p className="text-sm text-muted-foreground">
-                            Up next: {formatPatternName(status.playlist.next_file)}
-                          </p>
-                        )}
-                      </>
-                    ) : (
-                      <>
-                        <h2 className="text-lg md:text-xl font-semibold truncate">{patternName}</h2>
-                        {status?.playlist && (
-                          <p className="text-sm text-muted-foreground">
-                            Pattern {status.playlist.current_index + 1} of {status.playlist.total_files}
-                          </p>
-                        )}
-                      </>
-                    )}
-                  </div>
-                </div>
-
-                {/* Progress */}
-                {isWaiting ? (
-                  <div className="space-y-1 md:space-y-2">
-                    <Progress value={waitProgress} className="h-1.5 md:h-2" />
-                    <div className="flex justify-center items-center gap-2 text-xs md:text-sm text-muted-foreground font-mono">
-                      <span className="material-icons-outlined text-base">hourglass_top</span>
-                      <span>{formatTime(waitTimeRemaining)} remaining</span>
-                    </div>
-                  </div>
-                ) : (
-                  <div className="space-y-1 md:space-y-2">
-                    <Progress value={progressPercent} className="h-1.5 md:h-2" />
-                    <div className="flex justify-between text-xs md:text-sm text-muted-foreground font-mono">
-                      <span className="w-16">{formatTime(elapsedTime)}</span>
-                      <span>{progressPercent.toFixed(0)}%</span>
-                      <span className="w-16 flex items-center justify-end gap-1">
-                        {usingHistoricalEta && <span className="material-icons-outlined text-xs">history</span>}
-                        -{formatTime(remainingTime)}
-                      </span>
-                    </div>
-                  </div>
-                )}
-
-                {/* Playback Controls */}
-                <div className="flex items-center justify-center gap-2 md:gap-3">
-                  <Button
-                    variant="secondary"
-                    size="icon"
-                    className="h-10 w-10 md:h-12 md:w-12 rounded-full"
-                    onClick={handleStop}
-                    title="Stop"
-                  >
-                    <span className="material-icons text-lg md:text-2xl">stop</span>
-                  </Button>
-                  <Button
-                    variant="default"
-                    size="icon"
-                    className="h-12 w-12 md:h-14 md:w-14 rounded-full"
-                    onClick={handlePause}
-                  >
-                    <span className="material-icons text-xl md:text-2xl">
-                      {status?.is_paused ? 'play_arrow' : 'pause'}
-                    </span>
-                  </Button>
-                  {status?.playlist && (
-                    <Button
-                      variant="secondary"
-                      size="icon"
-                      className="h-10 w-10 md:h-12 md:w-12 rounded-full"
-                      onClick={handleSkip}
-                      title="Skip to next"
-                    >
-                      <span className="material-icons text-lg md:text-2xl">skip_next</span>
-                    </Button>
-                  )}
-                </div>
-
-                {/* Speed Control */}
-                <div className="flex items-center justify-center gap-2">
-                  <span className="text-sm text-muted-foreground">Speed:</span>
-                  <Input
-                    type="number"
-                    placeholder={String(status?.speed || 1000)}
-                    value={speedInput}
-                    onChange={(e) => setSpeedInput(e.target.value)}
-                    onKeyDown={(e) => e.key === 'Enter' && handleSpeedSubmit()}
-                    className="h-8 w-24 text-sm px-2"
-                  />
-                  <span className="text-sm text-muted-foreground">mm/s</span>
-                </div>
-
-                {/* Next Pattern */}
-                {status?.playlist?.next_file && (
-                  <div
-                    className="flex items-center gap-3 bg-muted/50 rounded-lg p-2 md:p-3 cursor-pointer hover:bg-muted/70 transition-colors"
-                    onClick={() => setShowQueue(true)}
-                    title="View queue"
-                  >
-                    <div className="w-10 h-10 md:w-12 md:h-12 rounded-full overflow-hidden bg-muted border shrink-0">
-                      {nextPreviewUrl ? (
-                        <img
-                          src={nextPreviewUrl}
-                          alt="Next pattern"
-                          className="w-full h-full object-cover pattern-preview"
-                        />
-                      ) : (
-                        <div className="w-full h-full flex items-center justify-center">
-                          <span className="material-icons-outlined text-muted-foreground text-sm">image</span>
-                        </div>
-                      )}
-                    </div>
-                    <div className="min-w-0 flex-1">
-                      <p className="text-xs text-muted-foreground">Up Next</p>
-                      <p className="text-sm font-medium truncate">
-                        {formatPatternName(status.playlist.next_file)}
-                      </p>
-                    </div>
-                    <span className="material-icons-outlined text-muted-foreground text-lg">queue_music</span>
-                  </div>
-                )}
-              </div>
-              </div>
-            </div>
-          )}
-        </div>
-        </div>{/* Close max-width container */}
-      </div>
-
-      {/* Queue Dialog */}
-      <Dialog open={showQueue} onOpenChange={setShowQueue}>
-        <DialogContent
-          ref={queueDialogRef}
-          className="max-w-md max-h-[80vh] flex flex-col"
-          onTouchStart={handleQueueTouchStart}
-          onTouchEnd={handleQueueTouchEnd}
-        >
-          {/* Swipe indicator for mobile */}
-          <div className="md:hidden flex justify-center -mt-2 mb-2">
-            <div className="w-10 h-1 bg-muted-foreground/30 rounded-full" />
-          </div>
-          <DialogHeader>
-            <DialogTitle className="flex items-center gap-2">
-              <span className="material-icons-outlined">queue_music</span>
-              Queue
-              {status?.playlist?.name && (
-                <span className="text-sm font-normal text-muted-foreground">
-                  — {status.playlist.name}
-                </span>
-              )}
-            </DialogTitle>
-            <DialogDescription className="sr-only">
-              List of patterns in the current playlist queue. Swipe down to dismiss.
-            </DialogDescription>
-          </DialogHeader>
-
-          <div className="flex-1 overflow-y-auto -mx-6 px-6 py-2" data-scrollable>
-            {status?.playlist && displayQueue.length > 0 ? (
-              (() => {
-                // Only show upcoming patterns (after current)
-                const currentIndex = status.playlist!.current_index
-                const upcomingFiles = displayQueue
-                  .map((file, index) => ({ file, index }))
-                  .filter(({ index }) => index > currentIndex)
-
-                if (upcomingFiles.length === 0) {
-                  return <p className="text-center text-muted-foreground py-8">No upcoming patterns</p>
-                }
-
-                const firstUpcomingIndex = upcomingFiles[0].index
-                const lastUpcomingIndex = upcomingFiles[upcomingFiles.length - 1].index
-
-                return (
-                  <DndContext
-                    sensors={sensors}
-                    collisionDetection={closestCenter}
-                    onDragEnd={handleDragEnd}
-                  >
-                    <SortableContext
-                      items={upcomingFiles.map(({ index }) => `queue-item-${index}`)}
-                      strategy={verticalListSortingStrategy}
-                    >
-                      <div className="space-y-1">
-                        {upcomingFiles.map(({ file, index }) => (
-                          <SortableQueueItem
-                            key={`queue-item-${index}`}
-                            id={`queue-item-${index}`}
-                            file={file}
-                            index={index}
-                            previewUrl={queuePreviews[file] || null}
-                            isFirst={index === firstUpcomingIndex}
-                            isLast={index === lastUpcomingIndex}
-                            onMoveToTop={() => moveToPosition(index, firstUpcomingIndex)}
-                            requestPreview={requestQueuePreview}
-                            onMoveToBottom={() => moveToPosition(index, lastUpcomingIndex)}
-                          />
-                        ))}
-                      </div>
-                    </SortableContext>
-                  </DndContext>
-                )
-              })()
-            ) : (
-              <p className="text-center text-muted-foreground py-8">No queue</p>
-            )}
-          </div>
-          {status?.playlist && (
-            <div className="pt-3 border-t text-xs text-muted-foreground flex justify-between">
-              <span>Mode: {status.playlist.mode}</span>
-              <span>
-                {status.playlist.current_index + 1} of {status.playlist.total_files}
-              </span>
-            </div>
-          )}
-        </DialogContent>
-      </Dialog>
-    </>
-  )
-}
+import { useState, useEffect, useRef, useCallback } from 'react'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import { Progress } from '@/components/ui/progress'
+import { Input } from '@/components/ui/input'
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+import { apiClient } from '@/lib/apiClient'
+import { useStatusStore } from '@/stores/useStatusStore'
+import type { StatusData } from '@/stores/useStatusStore'
+
+type Coordinate = [number, number]
+
+function formatTime(seconds: number): string {
+  if (!seconds || seconds < 0) return '--:--'
+  const mins = Math.floor(seconds / 60)
+  const secs = Math.floor(seconds % 60)
+  return `${mins}:${secs.toString().padStart(2, '0')}`
+}
+
+function formatPatternName(path: string | null): string {
+  if (!path) return 'Unknown'
+  // Extract filename without extension and path
+  const name = path.split('/').pop()?.replace('.thr', '') || path
+  return name
+}
+
+// Read-only queue item. The firmware owns the running queue (loaded into its
+// memory at playlist start), so the list shows what's coming without editing.
+interface QueueItemProps {
+  file: string
+  index: number
+  previewUrl: string | null
+  requestPreview: (file: string) => void
+}
+
+function QueueItem({ file, index, previewUrl, requestPreview }: QueueItemProps) {
+  const previewContainerRef = useRef<HTMLDivElement>(null)
+  const hasRequestedRef = useRef(false)
+
+  // Lazy load preview when item becomes visible
+  useEffect(() => {
+    if (!previewContainerRef.current || previewUrl || hasRequestedRef.current) return
+
+    const observer = new IntersectionObserver(
+      (entries) => {
+        entries.forEach((entry) => {
+          if (entry.isIntersecting && !hasRequestedRef.current) {
+            hasRequestedRef.current = true
+            requestPreview(file)
+            observer.disconnect()
+          }
+        })
+      },
+      { rootMargin: '50px' }
+    )
+
+    observer.observe(previewContainerRef.current)
+
+    return () => observer.disconnect()
+  }, [file, previewUrl, requestPreview])
+
+  return (
+    <div className="flex items-center gap-2 p-2 rounded-lg transition-colors hover:bg-muted/50">
+      {/* Preview thumbnail */}
+      <div ref={previewContainerRef} className="w-28 h-28 rounded-full overflow-hidden bg-muted border shrink-0">
+        {previewUrl ? (
+          <img
+            src={previewUrl}
+            alt=""
+            loading="lazy"
+            className="w-full h-full object-cover pattern-preview"
+          />
+        ) : (
+          <div className="w-full h-full flex items-center justify-center">
+            <span className="material-icons-outlined text-muted-foreground text-4xl">image</span>
+          </div>
+        )}
+      </div>
+
+      {/* Pattern name */}
+      <div className="flex-1 min-w-0">
+        <p className="text-sm truncate">{formatPatternName(file)}</p>
+        <p className="text-xs text-muted-foreground">#{index + 1}</p>
+      </div>
+    </div>
+  )
+}
+
+interface NowPlayingBarProps {
+  isLogsOpen?: boolean
+  logsDrawerHeight?: number
+  isVisible: boolean
+  openExpanded?: boolean
+  onClose: () => void
+}
+
+export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVisible, openExpanded = false, onClose }: NowPlayingBarProps) {
+  const status: StatusData | null = useStatusStore((s) => s.status)
+  const [previewUrl, setPreviewUrl] = useState<string | null>(null)
+
+  // Expanded state for slide-up view
+  const [isExpanded, setIsExpanded] = useState(false)
+
+  // Swipe gesture handling
+  const touchStartY = useRef<number | null>(null)
+  const barRef = useRef<HTMLDivElement>(null)
+
+  const handleTouchStart = (e: React.TouchEvent) => {
+    touchStartY.current = e.touches[0].clientY
+  }
+  const handleTouchEnd = (e: React.TouchEvent) => {
+    if (touchStartY.current === null) return
+    const touchEndY = e.changedTouches[0].clientY
+    const deltaY = touchEndY - touchStartY.current
+
+    if (deltaY > 50) {
+      // Swipe down
+      if (isExpanded) {
+        setIsExpanded(false) // Collapse to mini
+      } else {
+        onClose() // Hide the bar
+      }
+    } else if (deltaY < -50 && isPlaying) {
+      // Swipe up - expand (only if playing)
+      setIsExpanded(true)
+    }
+    touchStartY.current = null
+  }
+
+  // Prevent background scroll when Now Playing bar is visible
+  useEffect(() => {
+    if (isVisible) {
+      // Lock body scroll when bar is visible on mobile
+      document.body.style.overflow = 'hidden'
+      return () => {
+        document.body.style.overflow = ''
+      }
+    }
+  }, [isVisible])
+
+  // Use native event listener for touchmove to prevent background scroll on the bar itself
+  useEffect(() => {
+    const bar = barRef.current
+    if (!bar) return
+
+    const handleTouchMove = (e: TouchEvent) => {
+      // Only prevent default if not scrolling inside a scrollable element
+      const target = e.target as HTMLElement
+      const scrollableParent = target.closest('[data-scrollable]')
+      if (!scrollableParent) {
+        e.preventDefault()
+      }
+    }
+
+    bar.addEventListener('touchmove', handleTouchMove, { passive: false })
+    return () => {
+      bar.removeEventListener('touchmove', handleTouchMove)
+    }
+  }, [])
+
+  // Open in expanded mode when openExpanded prop changes to true
+  useEffect(() => {
+    if (openExpanded && isVisible) {
+      setIsExpanded(true)
+    }
+  }, [openExpanded, isVisible])
+
+  // Listen for playback-started event from Layout (more reliable than prop)
+  useEffect(() => {
+    const handlePlaybackStarted = () => {
+      setIsExpanded(true)
+    }
+    window.addEventListener('playback-started', handlePlaybackStarted)
+    return () => window.removeEventListener('playback-started', handlePlaybackStarted)
+  }, [])
+
+  // Auto-collapse when nothing is playing (with delay to avoid race condition)
+  // Include pause_time_remaining to keep UI active during countdown between patterns
+  const isPlaying = status?.is_running || status?.is_paused || (status?.pause_time_remaining ?? 0) > 0
+  const collapseTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
+  useEffect(() => {
+    // Clear any pending collapse
+    if (collapseTimeoutRef.current) {
+      clearTimeout(collapseTimeoutRef.current)
+      collapseTimeoutRef.current = null
+    }
+
+    if (!isPlaying && isExpanded) {
+      // Delay collapse to avoid race condition with playback-started
+      collapseTimeoutRef.current = setTimeout(() => {
+        setIsExpanded(false)
+      }, 500)
+    }
+
+    return () => {
+      if (collapseTimeoutRef.current) {
+        clearTimeout(collapseTimeoutRef.current)
+      }
+    }
+  }, [isPlaying, isExpanded])
+
+  const [coordinates, setCoordinates] = useState<Coordinate[]>([])
+  const canvasRef = useRef<HTMLCanvasElement>(null)
+  const offscreenCanvasRef = useRef<HTMLCanvasElement | null>(null)
+  const lastDrawnIndexRef = useRef<number>(-1)
+  const lastFileRef = useRef<string | null>(null)
+  const lastThemeRef = useRef<boolean | null>(null)
+
+  // Smooth animation refs
+  const animationFrameRef = useRef<number | null>(null)
+  const lastProgressRef = useRef<number>(0)
+  const lastProgressTimeRef = useRef<number>(0)
+  const smoothProgressRef = useRef<number>(0)
+
+  // Fetch preview images for current and next patterns
+  const [nextPreviewUrl, setNextPreviewUrl] = useState<string | null>(null)
+  const lastFetchedFilesRef = useRef<string>('')
+
+  useEffect(() => {
+    // Don't fetch if not visible
+    if (!isVisible) return
+
+    const currentFile = status?.current_file
+    const nextFile = status?.playlist?.next_file
+
+    // Build list of files to fetch
+    const filesToFetch = [currentFile, nextFile].filter(Boolean) as string[]
+    const fetchKey = filesToFetch.join('|')
+
+    // Skip if we already fetched these exact files
+    if (fetchKey === lastFetchedFilesRef.current) return
+    lastFetchedFilesRef.current = fetchKey
+
+    if (filesToFetch.length > 0) {
+      apiClient.post<Record<string, { image_data?: string }>>('/preview_thr_batch', { file_names: filesToFetch })
+        .then((data) => {
+          if (currentFile && data[currentFile]?.image_data) {
+            setPreviewUrl(data[currentFile].image_data)
+          } else {
+            setPreviewUrl(null)
+          }
+          if (nextFile && data[nextFile]?.image_data) {
+            setNextPreviewUrl(data[nextFile].image_data)
+          } else {
+            setNextPreviewUrl(null)
+          }
+        })
+        .catch(() => {
+          setPreviewUrl(null)
+          setNextPreviewUrl(null)
+        })
+    } else {
+      setPreviewUrl(null)
+      setNextPreviewUrl(null)
+    }
+  }, [isVisible, status?.current_file, status?.playlist?.next_file])
+
+  // Canvas drawing functions for real-time preview
+  const polarToCartesian = useCallback((theta: number, rho: number, size: number) => {
+    const centerX = size / 2
+    const centerY = size / 2
+    const radius = (size / 2) * 0.9 * rho
+    const x = centerX + radius * Math.cos(theta)
+    const y = centerY + radius * Math.sin(theta)
+    return { x, y }
+  }, [])
+
+  const getThemeColors = useCallback(() => {
+    const isDark = document.documentElement.classList.contains('dark')
+    return {
+      isDark,
+      bgOuter: isDark ? '#1a1a1a' : '#f5f5f5',
+      bgInner: isDark ? '#262626' : '#ffffff',
+      borderColor: isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(128, 128, 128, 0.3)',
+      lineColor: isDark ? '#e5e5e5' : '#333333',
+      markerBorder: isDark ? '#333333' : '#ffffff',
+    }
+  }, [])
+
+  const initOffscreenCanvas = useCallback((size: number, coords: Coordinate[]) => {
+    const colors = getThemeColors()
+
+    if (!offscreenCanvasRef.current) {
+      offscreenCanvasRef.current = document.createElement('canvas')
+    }
+
+    const offscreen = offscreenCanvasRef.current
+    offscreen.width = size
+    offscreen.height = size
+
+    const ctx = offscreen.getContext('2d')
+    if (!ctx) return
+
+    ctx.fillStyle = colors.bgOuter
+    ctx.fillRect(0, 0, size, size)
+
+    ctx.beginPath()
+    ctx.arc(size / 2, size / 2, (size / 2) * 0.95, 0, Math.PI * 2)
+    ctx.fillStyle = colors.bgInner
+    ctx.fill()
+    ctx.strokeStyle = colors.borderColor
+    ctx.lineWidth = 1
+    ctx.stroke()
+
+    ctx.strokeStyle = colors.lineColor
+    ctx.lineWidth = 1.5
+    ctx.lineCap = 'round'
+    ctx.lineJoin = 'round'
+
+    if (coords.length > 0) {
+      const firstPoint = polarToCartesian(coords[0][0], coords[0][1], size)
+      ctx.beginPath()
+      ctx.moveTo(firstPoint.x, firstPoint.y)
+      ctx.stroke()
+    }
+
+    lastDrawnIndexRef.current = 0
+    lastThemeRef.current = colors.isDark
+  }, [getThemeColors, polarToCartesian])
+
+  const drawPattern = useCallback((ctx: CanvasRenderingContext2D, coords: Coordinate[], smoothIndex: number, forceRedraw = false) => {
+    const canvas = ctx.canvas
+    const size = canvas.width
+    const colors = getThemeColors()
+
+    // Apply 16 coordinate offset for physical latency
+    const adjustedSmoothIndex = Math.max(0, smoothIndex - 16)
+    const adjustedIndex = Math.floor(adjustedSmoothIndex)
+
+    const needsReinit = forceRedraw ||
+      !offscreenCanvasRef.current ||
+      lastThemeRef.current !== colors.isDark ||
+      adjustedIndex < lastDrawnIndexRef.current
+
+    if (needsReinit) {
+      initOffscreenCanvas(size, coords)
+    }
+
+    const offscreen = offscreenCanvasRef.current
+    if (!offscreen) return
+
+    const offCtx = offscreen.getContext('2d')
+    if (!offCtx) return
+
+    if (coords.length > 0 && adjustedIndex > lastDrawnIndexRef.current) {
+      offCtx.strokeStyle = colors.lineColor
+      offCtx.lineWidth = 1.5
+      offCtx.lineCap = 'round'
+      offCtx.lineJoin = 'round'
+
+      offCtx.beginPath()
+      const startPoint = polarToCartesian(
+        coords[lastDrawnIndexRef.current][0],
+        coords[lastDrawnIndexRef.current][1],
+        size
+      )
+      offCtx.moveTo(startPoint.x, startPoint.y)
+
+      for (let i = lastDrawnIndexRef.current + 1; i <= adjustedIndex && i < coords.length; i++) {
+        const point = polarToCartesian(coords[i][0], coords[i][1], size)
+        offCtx.lineTo(point.x, point.y)
+      }
+      offCtx.stroke()
+
+      lastDrawnIndexRef.current = adjustedIndex
+    }
+
+    ctx.drawImage(offscreen, 0, 0)
+
+    // Draw current position marker with smooth interpolation between coordinates
+    if (coords.length > 0 && adjustedIndex < coords.length - 1) {
+      const fraction = adjustedSmoothIndex - adjustedIndex
+      const currentCoord = coords[adjustedIndex]
+      const nextCoord = coords[Math.min(adjustedIndex + 1, coords.length - 1)]
+
+      // Interpolate theta and rho
+      const interpTheta = currentCoord[0] + (nextCoord[0] - currentCoord[0]) * fraction
+      const interpRho = currentCoord[1] + (nextCoord[1] - currentCoord[1]) * fraction
+
+      const currentPoint = polarToCartesian(interpTheta, interpRho, size)
+      ctx.beginPath()
+      ctx.arc(currentPoint.x, currentPoint.y, 8, 0, Math.PI * 2)
+      ctx.fillStyle = '#0b80ee'
+      ctx.fill()
+      ctx.strokeStyle = colors.markerBorder
+      ctx.lineWidth = 2
+      ctx.stroke()
+    } else if (coords.length > 0 && adjustedIndex < coords.length) {
+      // At the last coordinate, just draw without interpolation
+      const currentPoint = polarToCartesian(coords[adjustedIndex][0], coords[adjustedIndex][1], size)
+      ctx.beginPath()
+      ctx.arc(currentPoint.x, currentPoint.y, 8, 0, Math.PI * 2)
+      ctx.fillStyle = '#0b80ee'
+      ctx.fill()
+      ctx.strokeStyle = colors.markerBorder
+      ctx.lineWidth = 2
+      ctx.stroke()
+    }
+  }, [getThemeColors, initOffscreenCanvas, polarToCartesian])
+
+  // Fetch coordinates when file changes or fullscreen opens
+  useEffect(() => {
+    const currentFile = status?.current_file
+    if (!currentFile) return
+
+    // Only fetch if file changed or we don't have coordinates yet
+    const needsFetch = currentFile !== lastFileRef.current || coordinates.length === 0
+
+    if (!needsFetch) return
+
+    lastFileRef.current = currentFile
+    lastDrawnIndexRef.current = -1
+
+    apiClient.post<{ coordinates?: Coordinate[] }>('/get_theta_rho_coordinates', { file_name: currentFile })
+      .then((data) => {
+        if (data.coordinates && Array.isArray(data.coordinates)) {
+          setCoordinates(data.coordinates)
+        }
+      })
+      .catch((err) => {
+        console.error('Failed to fetch coordinates:', err)
+        setCoordinates([])
+      })
+  }, [status?.current_file, coordinates.length])
+
+  // Get target index from progress percentage
+  const getTargetIndex = useCallback((coords: Coordinate[]): number => {
+    if (coords.length === 0) return 0
+    const progressPercent = status?.progress?.percentage || 0
+    return (progressPercent / 100) * coords.length
+  }, [status?.progress?.percentage])
+
+  // Track progress updates for smooth interpolation
+  useEffect(() => {
+    const currentProgress = status?.progress?.percentage || 0
+    if (currentProgress !== lastProgressRef.current) {
+      lastProgressRef.current = currentProgress
+      lastProgressTimeRef.current = performance.now()
+    }
+  }, [status?.progress?.percentage])
+
+  // Smooth animation loop
+  useEffect(() => {
+    if (!isExpanded || coordinates.length === 0) return
+
+    const isPaused = status?.is_paused || false
+    const coordsPerSecond = 4.2
+
+    const animate = () => {
+      if (!canvasRef.current) return
+
+      const ctx = canvasRef.current.getContext('2d')
+      if (!ctx) return
+
+      const targetIndex = getTargetIndex(coordinates)
+      const now = performance.now()
+      const timeSinceUpdate = (now - lastProgressTimeRef.current) / 1000
+
+      let smoothIndex: number
+      if (isPaused) {
+        // When paused, just use the target index directly
+        smoothIndex = targetIndex
+      } else {
+        // Interpolate: start from where we were at last update, advance based on time
+        const baseIndex = (lastProgressRef.current / 100) * coordinates.length
+        smoothIndex = baseIndex + (timeSinceUpdate * coordsPerSecond)
+        // Don't overshoot the target too much
+        smoothIndex = Math.min(smoothIndex, targetIndex + 2)
+      }
+
+      smoothProgressRef.current = smoothIndex
+      drawPattern(ctx, coordinates, smoothIndex)
+
+      animationFrameRef.current = requestAnimationFrame(animate)
+    }
+
+    // Initial draw with force redraw
+    const timer = setTimeout(() => {
+      if (!canvasRef.current) return
+      const ctx = canvasRef.current.getContext('2d')
+      if (!ctx) return
+
+      lastDrawnIndexRef.current = -1
+      offscreenCanvasRef.current = null
+      smoothProgressRef.current = getTargetIndex(coordinates)
+      lastProgressTimeRef.current = performance.now()
+
+      drawPattern(ctx, coordinates, smoothProgressRef.current, true)
+
+      // Start animation loop
+      animationFrameRef.current = requestAnimationFrame(animate)
+    }, 50)
+
+    return () => {
+      clearTimeout(timer)
+      if (animationFrameRef.current) {
+        cancelAnimationFrame(animationFrameRef.current)
+      }
+    }
+  }, [isExpanded, coordinates, status?.is_paused, drawPattern, getTargetIndex])
+
+  const handlePause = async () => {
+    try {
+      const endpoint = status?.is_paused ? '/resume_execution' : '/pause_execution'
+      await apiClient.post(endpoint)
+      toast.success(status?.is_paused ? 'Resumed' : 'Paused')
+    } catch (error) {
+      // Extract error detail from backend response (format: "HTTP 400: {"detail":"message"}")
+      let errorMessage = 'Failed to toggle pause'
+      if (error instanceof Error) {
+        try {
+          const jsonMatch = error.message.match(/\{.*\}/)
+          if (jsonMatch) {
+            const parsed = JSON.parse(jsonMatch[0])
+            if (parsed.detail) {
+              errorMessage = parsed.detail
+            }
+          }
+        } catch {
+          // Keep default message if parsing fails
+        }
+      }
+      toast.error(errorMessage)
+    }
+  }
+
+  const handleStop = async () => {
+    try {
+      await apiClient.post('/stop_execution')
+      toast.success('Stopped')
+    } catch {
+      // Normal stop failed, try force stop
+      try {
+        await apiClient.post('/force_stop')
+        toast.success('Force stopped')
+      } catch {
+        toast.error('Failed to stop')
+      }
+    }
+  }
+
+  const handleSkip = async () => {
+    try {
+      await apiClient.post('/skip_pattern')
+      toast.success('Skipping to next pattern')
+    } catch {
+      toast.error('Failed to skip')
+    }
+  }
+
+  const [speedInput, setSpeedInput] = useState('')
+  const [showQueue, setShowQueue] = useState(false)
+  const [queuePreviews, setQueuePreviews] = useState<Record<string, string>>({})
+
+  // Queue dialog swipe-to-dismiss
+  const queueTouchStartY = useRef<number | null>(null)
+  const queueDialogRef = useRef<HTMLDivElement>(null)
+
+  const handleQueueTouchStart = (e: React.TouchEvent) => {
+    queueTouchStartY.current = e.touches[0].clientY
+  }
+
+  const handleQueueTouchEnd = (e: React.TouchEvent) => {
+    if (queueTouchStartY.current === null) return
+    const touchEndY = e.changedTouches[0].clientY
+    const deltaY = touchEndY - queueTouchStartY.current
+
+    // Swipe down to dismiss (only if at top of scroll or large swipe)
+    if (deltaY > 80) {
+      const scrollContainer = queueDialogRef.current?.querySelector('[data-scrollable]') as HTMLElement
+      const isAtTop = !scrollContainer || scrollContainer.scrollTop <= 0
+      if (isAtTop) {
+        setShowQueue(false)
+      }
+    }
+    queueTouchStartY.current = null
+  }
+
+  // The firmware owns the running queue; the host mirror is read-only.
+  const displayQueue = status?.playlist?.files || []
+
+  const handleSpeedSubmit = async () => {
+    const speed = parseInt(speedInput)
+    if (isNaN(speed) || speed < 10 || speed > 6000) {
+      toast.error('Speed must be between 10 and 6000 mm/s')
+      return
+    }
+    try {
+      await apiClient.post('/set_speed', { speed })
+      setSpeedInput('')
+      toast.success(`Speed set to ${speed} mm/s`)
+    } catch {
+      toast.error('Failed to set speed')
+    }
+  }
+
+  // Track which files we've already requested previews for
+  const requestedPreviewsRef = useRef<Set<string>>(new Set())
+  const pendingQueuePreviewsRef = useRef<Set<string>>(new Set())
+  const batchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
+
+  // Batched queue preview fetching - collects requests and fetches in batches
+  const requestQueuePreview = useCallback((file: string) => {
+    // Skip if already loaded or pending
+    if (queuePreviews[file] || requestedPreviewsRef.current.has(file) || pendingQueuePreviewsRef.current.has(file)) return
+
+    pendingQueuePreviewsRef.current.add(file)
+
+    // Debounce batch fetch
+    if (batchTimeoutRef.current) clearTimeout(batchTimeoutRef.current)
+    batchTimeoutRef.current = setTimeout(async () => {
+      const filesToFetch = Array.from(pendingQueuePreviewsRef.current)
+      pendingQueuePreviewsRef.current.clear()
+      if (filesToFetch.length === 0) return
+
+      // Mark as requested
+      filesToFetch.forEach(f => requestedPreviewsRef.current.add(f))
+
+      try {
+        const data = await apiClient.post<Record<string, { image_data?: string }>>('/preview_thr_batch', { file_names: filesToFetch })
+        const newPreviews: Record<string, string> = {}
+        for (const [file, result] of Object.entries(data)) {
+          if (result.image_data) {
+            newPreviews[file] = result.image_data
+          }
+        }
+        if (Object.keys(newPreviews).length > 0) {
+          setQueuePreviews(prev => ({ ...prev, ...newPreviews }))
+        }
+      } catch (err) {
+        console.error('Failed to fetch queue previews:', err)
+      }
+    }, 100)
+  }, [queuePreviews])
+
+  // Don't render if not visible
+  if (!isVisible) {
+    return null
+  }
+
+  const patternName = formatPatternName(status?.current_file ?? null)
+  const progressPercent = status?.progress?.percentage || 0
+  const tqdmRemainingTime = status?.progress?.remaining_time || 0
+  const elapsedTime = status?.progress?.elapsed_time || 0
+
+  // Use historical time if available, otherwise fall back to tqdm estimate
+  const historicalTime = status?.progress?.last_completed_time?.actual_time_seconds
+  const remainingTime = historicalTime
+    ? Math.max(0, historicalTime - elapsedTime)
+    : tqdmRemainingTime
+  const usingHistoricalEta = !!historicalTime
+
+  // Detect waiting state between patterns
+  const isWaiting = (status?.pause_time_remaining ?? 0) > 0
+  const waitTimeRemaining = status?.pause_time_remaining ?? 0
+  const originalWaitTime = status?.original_pause_time ?? 0
+  const waitProgress = originalWaitTime > 0 ? ((originalWaitTime - waitTimeRemaining) / originalWaitTime) * 100 : 0
+
+  return (
+    <>
+      {/* Backdrop when expanded */}
+      {isExpanded && (
+        <div
+          className="fixed inset-0 bg-black/30 z-30"
+          onClick={() => setIsExpanded(false)}
+        />
+      )}
+
+      {/* Now Playing Bar - slides up to full height on mobile, 50vh on desktop when expanded */}
+      <div
+        ref={barRef}
+        className="fixed left-0 right-0 z-40 bg-background border-t shadow-lg transition-all duration-300"
+        style={{
+          bottom: isLogsOpen
+            ? `calc(${logsDrawerHeight}px + 4rem + env(safe-area-inset-bottom, 0px))`
+            : 'calc(4rem + env(safe-area-inset-bottom, 0px))'
+        }}
+        data-now-playing-bar={isExpanded ? 'expanded' : 'collapsed'}
+        onTouchStart={handleTouchStart}
+        onTouchEnd={handleTouchEnd}
+      >
+        {/* Max-width container to match page layout */}
+        <div className="h-full max-w-5xl mx-auto relative">
+          {/* Swipe indicator - only on mobile */}
+          <div className="md:hidden flex justify-center pt-2 pb-1">
+            <div className="w-10 h-1 bg-muted-foreground/30 rounded-full" />
+          </div>
+
+          {/* Header with action buttons - add safe area when expanded for Dynamic Island */}
+          <div className={`absolute right-3 sm:right-4 flex items-center gap-1 z-10 ${isExpanded ? 'top-3 mt-safe' : 'top-3'}`}>
+          {/* Queue button - mobile only, when playlist exists */}
+          {isPlaying && status?.playlist && (
+            <Button
+              variant="ghost"
+              size="icon"
+              className="md:hidden h-8 w-8"
+              onClick={() => setShowQueue(true)}
+              title="View queue"
+            >
+              <span className="material-icons-outlined text-lg">queue_music</span>
+            </Button>
+          )}
+          {isPlaying && (
+            <Button
+              variant="ghost"
+              size="icon"
+              className="h-8 w-8"
+              onClick={() => setIsExpanded(!isExpanded)}
+              title={isExpanded ? 'Collapse' : 'Expand'}
+            >
+              <span className="material-icons-outlined text-lg">
+                {isExpanded ? 'expand_more' : 'expand_less'}
+              </span>
+            </Button>
+          )}
+          <Button
+            variant="ghost"
+            size="icon"
+            className="h-8 w-8"
+            onClick={onClose}
+            title="Close"
+          >
+            <span className="material-icons-outlined text-lg">close</span>
+          </Button>
+        </div>
+
+        {/* Content container */}
+        <div className="h-full flex flex-col">
+          {/* Collapsed view - Mini Bar */}
+          {!isExpanded && (
+            <div className="flex-1 flex flex-col">
+              {/* Main row with preview and controls */}
+              <div className="flex-1 flex items-center gap-6 px-6 py-4">
+                {/* Current Pattern Preview - Rounded (click to expand) */}
+                <div
+                  className="w-48 h-48 rounded-full overflow-hidden bg-muted shrink-0 border-2 cursor-pointer hover:border-primary transition-colors"
+                  onClick={() => isPlaying && setIsExpanded(true)}
+                  title={isPlaying ? 'Click to expand' : undefined}
+                >
+                  {previewUrl && isPlaying ? (
+                    <img
+                      src={previewUrl}
+                      alt={patternName}
+                      className="w-full h-full object-cover pattern-preview"
+                    />
+                  ) : (
+                    <div className="w-full h-full flex items-center justify-center">
+                      <span className="material-icons-outlined text-muted-foreground text-4xl">
+                        {isPlaying ? 'image' : 'hourglass_empty'}
+                      </span>
+                    </div>
+                  )}
+                </div>
+
+                {/* Main Content Area */}
+                {isPlaying && status ? (
+                  <>
+                    <div className="flex-1 min-w-0 flex flex-col justify-center gap-2 py-2">
+                      {/* Title Row */}
+                      <div className="flex items-center gap-3 pr-12 md:pr-16">
+                        <div className="flex-1 min-w-0">
+                          {isWaiting ? (
+                            <>
+                              <p className="text-sm md:text-base font-semibold text-muted-foreground">
+                                Waiting for next pattern...
+                              </p>
+                              {status.playlist?.next_file && (
+                                <p className="text-xs text-muted-foreground">
+                                  Up next: {formatPatternName(status.playlist.next_file)}
+                                </p>
+                              )}
+                            </>
+                          ) : (
+                            <>
+                              <p className="text-sm md:text-base font-semibold truncate">
+                                {patternName}
+                              </p>
+                              {status.playlist && (
+                                <p className="text-xs text-muted-foreground">
+                                  Pattern {status.playlist.current_index + 1} of {status.playlist.total_files}
+                                </p>
+                              )}
+                            </>
+                          )}
+                        </div>
+                      </div>
+
+                      {/* Progress Bar - Desktop only (inline, above controls) */}
+                      {isWaiting ? (
+                        <div className="hidden md:flex items-center gap-3">
+                          <span className="material-icons-outlined text-muted-foreground text-lg">hourglass_top</span>
+                          <Progress value={waitProgress} className="h-2 flex-1" />
+                          <span className="text-sm text-muted-foreground font-mono">{formatTime(waitTimeRemaining)}</span>
+                        </div>
+                      ) : (
+                        <div className="hidden md:flex items-center gap-3">
+                          <span className="text-sm text-muted-foreground w-12 font-mono">{formatTime(elapsedTime)}</span>
+                          <Progress value={progressPercent} className="h-2 flex-1" />
+                          <span
+                            className={`text-sm text-muted-foreground text-right font-mono flex items-center justify-end gap-1.5 shrink-0 ${usingHistoricalEta ? 'w-24' : 'w-14'}`}
+                            title={usingHistoricalEta ? 'ETA based on last completed run' : 'Estimated time remaining'}
+                          >
+                            {usingHistoricalEta && <span className="material-icons-outlined text-sm">history</span>}
+                            -{formatTime(remainingTime)}
+                          </span>
+                        </div>
+                      )}
+
+                      {/* Playback Controls - Centered */}
+                      <div className="flex items-center justify-center gap-3">
+                        <Button
+                          variant="secondary"
+                          size="icon"
+                          className="h-10 w-10 rounded-full"
+                          onClick={handleStop}
+                          title="Stop"
+                        >
+                          <span className="material-icons">stop</span>
+                        </Button>
+                        <Button
+                          variant="default"
+                          size="icon"
+                          className="h-12 w-12 rounded-full"
+                          onClick={handlePause}
+                        >
+                          <span className="material-icons text-xl">
+                            {status.is_paused ? 'play_arrow' : 'pause'}
+                          </span>
+                        </Button>
+                        {status.playlist && (
+                          <Button
+                            variant="secondary"
+                            size="icon"
+                            className="h-10 w-10 rounded-full"
+                            onClick={handleSkip}
+                            title="Skip to next"
+                          >
+                            <span className="material-icons">skip_next</span>
+                          </Button>
+                        )}
+                      </div>
+
+                      {/* Speed Control */}
+                      <div className="flex items-center justify-center gap-2">
+                        <span className="text-sm text-muted-foreground">Speed:</span>
+                        <Input
+                          type="number"
+                          placeholder={String(status.speed)}
+                          value={speedInput}
+                          onChange={(e) => setSpeedInput(e.target.value)}
+                          onKeyDown={(e) => e.key === 'Enter' && handleSpeedSubmit()}
+                          className="h-7 w-20 text-sm px-2"
+                        />
+                        <span className="text-sm text-muted-foreground">mm/s</span>
+                      </div>
+                    </div>
+
+                    {/* Next Pattern Preview - hidden on mobile */}
+                    {status.playlist?.next_file && (
+                      <div
+                        className="hidden md:flex shrink-0 flex-col items-center gap-1 mr-16 cursor-pointer hover:opacity-80 transition-opacity"
+                        onClick={() => setShowQueue(true)}
+                        title="View queue"
+                      >
+                        <p className="text-xs text-muted-foreground font-medium flex items-center gap-1">
+                          Up Next
+                          <span className="material-icons-outlined text-xs">queue_music</span>
+                        </p>
+                        <div className="w-24 h-24 rounded-full overflow-hidden bg-muted border-2">
+                          {nextPreviewUrl ? (
+                            <img
+                              src={nextPreviewUrl}
+                              alt="Next pattern"
+                              className="w-full h-full object-cover pattern-preview"
+                            />
+                          ) : (
+                            <div className="w-full h-full flex items-center justify-center">
+                              <span className="material-icons-outlined text-muted-foreground text-2xl">image</span>
+                            </div>
+                          )}
+                        </div>
+                        <p className="text-xs text-muted-foreground text-center max-w-24 truncate">
+                          {formatPatternName(status.playlist.next_file)}
+                        </p>
+                      </div>
+                    )}
+                  </>
+                ) : (
+                  <div className="flex-1 flex items-center">
+                    <p className="text-lg text-muted-foreground">Not playing</p>
+                  </div>
+                )}
+              </div>
+
+              {/* Progress Bar - Mobile only (full width at bottom) */}
+              {isPlaying && status && (
+                isWaiting ? (
+                  <div className="flex md:hidden items-center gap-3 px-6 pb-16">
+                    <span className="material-icons-outlined text-muted-foreground text-lg">hourglass_top</span>
+                    <Progress value={waitProgress} className="h-2 flex-1" />
+                    <span className="text-sm text-muted-foreground font-mono">{formatTime(waitTimeRemaining)}</span>
+                  </div>
+                ) : (
+                  <div className="flex md:hidden items-center gap-3 px-6 pb-16">
+                    <span className="text-sm text-muted-foreground w-12 font-mono">{formatTime(elapsedTime)}</span>
+                    <Progress value={progressPercent} className="h-2 flex-1" />
+                    <span className={`text-sm text-muted-foreground text-right font-mono flex items-center justify-end gap-1.5 shrink-0 ${usingHistoricalEta ? 'w-24' : 'w-14'}`}>
+                      {usingHistoricalEta && <span className="material-icons-outlined text-sm">history</span>}
+                      -{formatTime(remainingTime)}
+                    </span>
+                  </div>
+                )
+              )}
+            </div>
+          )}
+
+          {/* Expanded view - Real-time canvas preview */}
+          {isExpanded && isPlaying && (
+            <div className="flex-1 flex flex-col md:items-center md:justify-center px-4 py-4 md:py-8 pt-safe overflow-hidden">
+              <div className="w-full max-w-5xl mx-auto flex flex-col md:flex-row md:items-center gap-3 md:gap-6">
+                {/* Canvas - full width on mobile (click to collapse) */}
+                <div
+                  className="flex-1 flex items-center justify-center cursor-pointer"
+                  onClick={() => setIsExpanded(false)}
+                  title="Click to collapse"
+                >
+                  <canvas
+                    ref={canvasRef}
+                    width={600}
+                    height={600}
+                    className="rounded-full border-2 hover:border-primary transition-colors w-[40vh] h-[40vh] max-w-[300px] max-h-[300px] md:w-[42vh] md:h-[42vh] md:max-w-[500px] md:max-h-[500px]"
+                  />
+                </div>
+
+                {/* Controls */}
+                <div className="md:w-80 shrink-0 flex flex-col justify-start md:justify-center gap-2 md:gap-4">
+                {/* Pattern Info */}
+                <div className="flex items-center justify-center gap-3">
+                  {/* Current pattern preview */}
+                  <div className="w-10 h-10 md:w-12 md:h-12 rounded-full overflow-hidden bg-muted border shrink-0">
+                    {previewUrl ? (
+                      <img
+                        src={previewUrl}
+                        alt={patternName}
+                        className="w-full h-full object-cover pattern-preview"
+                      />
+                    ) : (
+                      <div className="w-full h-full flex items-center justify-center">
+                        <span className="material-icons-outlined text-muted-foreground text-sm">image</span>
+                      </div>
+                    )}
+                  </div>
+                  <div className="text-left min-w-0">
+                    {isWaiting ? (
+                      <>
+                        <h2 className="text-lg md:text-xl font-semibold text-muted-foreground">
+                          Waiting for next pattern...
+                        </h2>
+                        {status?.playlist?.next_file && (
+                          <p className="text-sm text-muted-foreground">
+                            Up next: {formatPatternName(status.playlist.next_file)}
+                          </p>
+                        )}
+                      </>
+                    ) : (
+                      <>
+                        <h2 className="text-lg md:text-xl font-semibold truncate">{patternName}</h2>
+                        {status?.playlist && (
+                          <p className="text-sm text-muted-foreground">
+                            Pattern {status.playlist.current_index + 1} of {status.playlist.total_files}
+                          </p>
+                        )}
+                      </>
+                    )}
+                  </div>
+                </div>
+
+                {/* Progress */}
+                {isWaiting ? (
+                  <div className="space-y-1 md:space-y-2">
+                    <Progress value={waitProgress} className="h-1.5 md:h-2" />
+                    <div className="flex justify-center items-center gap-2 text-xs md:text-sm text-muted-foreground font-mono">
+                      <span className="material-icons-outlined text-base">hourglass_top</span>
+                      <span>{formatTime(waitTimeRemaining)} remaining</span>
+                    </div>
+                  </div>
+                ) : (
+                  <div className="space-y-1 md:space-y-2">
+                    <Progress value={progressPercent} className="h-1.5 md:h-2" />
+                    <div className="flex justify-between text-xs md:text-sm text-muted-foreground font-mono">
+                      <span className="w-16">{formatTime(elapsedTime)}</span>
+                      <span>{progressPercent.toFixed(0)}%</span>
+                      <span className="w-16 flex items-center justify-end gap-1">
+                        {usingHistoricalEta && <span className="material-icons-outlined text-xs">history</span>}
+                        -{formatTime(remainingTime)}
+                      </span>
+                    </div>
+                  </div>
+                )}
+
+                {/* Playback Controls */}
+                <div className="flex items-center justify-center gap-2 md:gap-3">
+                  <Button
+                    variant="secondary"
+                    size="icon"
+                    className="h-10 w-10 md:h-12 md:w-12 rounded-full"
+                    onClick={handleStop}
+                    title="Stop"
+                  >
+                    <span className="material-icons text-lg md:text-2xl">stop</span>
+                  </Button>
+                  <Button
+                    variant="default"
+                    size="icon"
+                    className="h-12 w-12 md:h-14 md:w-14 rounded-full"
+                    onClick={handlePause}
+                  >
+                    <span className="material-icons text-xl md:text-2xl">
+                      {status?.is_paused ? 'play_arrow' : 'pause'}
+                    </span>
+                  </Button>
+                  {status?.playlist && (
+                    <Button
+                      variant="secondary"
+                      size="icon"
+                      className="h-10 w-10 md:h-12 md:w-12 rounded-full"
+                      onClick={handleSkip}
+                      title="Skip to next"
+                    >
+                      <span className="material-icons text-lg md:text-2xl">skip_next</span>
+                    </Button>
+                  )}
+                </div>
+
+                {/* Speed Control */}
+                <div className="flex items-center justify-center gap-2">
+                  <span className="text-sm text-muted-foreground">Speed:</span>
+                  <Input
+                    type="number"
+                    placeholder={String(status?.speed || 1000)}
+                    value={speedInput}
+                    onChange={(e) => setSpeedInput(e.target.value)}
+                    onKeyDown={(e) => e.key === 'Enter' && handleSpeedSubmit()}
+                    className="h-8 w-24 text-sm px-2"
+                  />
+                  <span className="text-sm text-muted-foreground">mm/s</span>
+                </div>
+
+                {/* Next Pattern */}
+                {status?.playlist?.next_file && (
+                  <div
+                    className="flex items-center gap-3 bg-muted/50 rounded-lg p-2 md:p-3 cursor-pointer hover:bg-muted/70 transition-colors"
+                    onClick={() => setShowQueue(true)}
+                    title="View queue"
+                  >
+                    <div className="w-10 h-10 md:w-12 md:h-12 rounded-full overflow-hidden bg-muted border shrink-0">
+                      {nextPreviewUrl ? (
+                        <img
+                          src={nextPreviewUrl}
+                          alt="Next pattern"
+                          className="w-full h-full object-cover pattern-preview"
+                        />
+                      ) : (
+                        <div className="w-full h-full flex items-center justify-center">
+                          <span className="material-icons-outlined text-muted-foreground text-sm">image</span>
+                        </div>
+                      )}
+                    </div>
+                    <div className="min-w-0 flex-1">
+                      <p className="text-xs text-muted-foreground">Up Next</p>
+                      <p className="text-sm font-medium truncate">
+                        {formatPatternName(status.playlist.next_file)}
+                      </p>
+                    </div>
+                    <span className="material-icons-outlined text-muted-foreground text-lg">queue_music</span>
+                  </div>
+                )}
+              </div>
+              </div>
+            </div>
+          )}
+        </div>
+        </div>{/* Close max-width container */}
+      </div>
+
+      {/* Queue Dialog */}
+      <Dialog open={showQueue} onOpenChange={setShowQueue}>
+        <DialogContent
+          ref={queueDialogRef}
+          className="max-w-md max-h-[80vh] flex flex-col"
+          onTouchStart={handleQueueTouchStart}
+          onTouchEnd={handleQueueTouchEnd}
+        >
+          {/* Swipe indicator for mobile */}
+          <div className="md:hidden flex justify-center -mt-2 mb-2">
+            <div className="w-10 h-1 bg-muted-foreground/30 rounded-full" />
+          </div>
+          <DialogHeader>
+            <DialogTitle className="flex items-center gap-2">
+              <span className="material-icons-outlined">queue_music</span>
+              Queue
+              {status?.playlist?.name && (
+                <span className="text-sm font-normal text-muted-foreground">
+                  — {status.playlist.name}
+                </span>
+              )}
+            </DialogTitle>
+            <DialogDescription className="sr-only">
+              List of patterns in the current playlist queue. Swipe down to dismiss.
+            </DialogDescription>
+          </DialogHeader>
+
+          <div className="flex-1 overflow-y-auto -mx-6 px-6 py-2" data-scrollable>
+            {status?.playlist && displayQueue.length > 0 ? (
+              (() => {
+                const shuffled = Boolean(status.playlist!.shuffled)
+                // With firmware-side shuffle the played order is unknown to the
+                // host — show the playlist's contents instead of "up next".
+                const currentIndex = status.playlist!.current_index
+                const items = shuffled
+                  ? displayQueue.map((file, index) => ({ file, index }))
+                  : displayQueue
+                      .map((file, index) => ({ file, index }))
+                      .filter(({ index }) => index > currentIndex)
+
+                if (items.length === 0) {
+                  return <p className="text-center text-muted-foreground py-8">No upcoming patterns</p>
+                }
+
+                return (
+                  <div className="space-y-1">
+                    {shuffled && (
+                      <p className="text-xs text-muted-foreground px-2 pb-1">
+                        Shuffle is on — the table picks the order, so this lists the playlist's patterns, not the play order.
+                      </p>
+                    )}
+                    {items.map(({ file, index }) => (
+                      <QueueItem
+                        key={`queue-item-${index}`}
+                        file={file}
+                        index={index}
+                        previewUrl={queuePreviews[file] || null}
+                        requestPreview={requestQueuePreview}
+                      />
+                    ))}
+                  </div>
+                )
+              })()
+            ) : (
+              <p className="text-center text-muted-foreground py-8">No queue</p>
+            )}
+          </div>
+          {status?.playlist && (
+            <div className="pt-3 border-t text-xs text-muted-foreground flex justify-between">
+              <span>Mode: {status.playlist.mode}</span>
+              <span>
+                {status.playlist.current_index + 1} of {status.playlist.total_files}
+              </span>
+            </div>
+          )}
+        </DialogContent>
+      </Dialog>
+    </>
+  )
+}

+ 1 - 1
frontend/src/components/layout/Layout.tsx

@@ -687,7 +687,7 @@ export function Layout() {
   const isSecurityUnlocked = securityMode !== 'off' && hasSecurityPassword && isUnlocked
 
   // Redirect away from restricted pages if play_only is active and not unlocked
-  const restrictedPaths = ['/settings', '/table-control', '/setup', '/wifi-setup']
+  const restrictedPaths = ['/settings', '/table-control', '/wifi-setup']
   useEffect(() => {
     if (isPlayOnlyActive && restrictedPaths.includes(location.pathname)) {
       navigate('/')

+ 0 - 40
frontend/src/pages/BrowsePage.tsx

@@ -763,25 +763,6 @@ export function BrowsePage() {
     }
   }
 
-  const handleAddToQueue = async (position: 'next' | 'end') => {
-    if (!selectedPattern) return
-
-    try {
-      await apiClient.post('/add_to_queue', {
-        pattern: selectedPattern.path,
-        position,
-      })
-      toast.success(position === 'next' ? 'Playing next' : 'Added to queue')
-    } catch (error) {
-      const message = error instanceof Error ? error.message : 'Failed to add to queue'
-      if (message.includes('400') || message.includes('No playlist')) {
-        toast.error('No playlist is currently running')
-      } else {
-        toast.error(message)
-      }
-    }
-  }
-
   const getPreviewUrl = (path: string) => {
     const preview = previews[path]
     return preview?.image_data || null
@@ -1243,27 +1224,6 @@ export function BrowsePage() {
                   )}
                 </div>
 
-                {/* Queue buttons */}
-                <div className="flex gap-2">
-                  <Button
-                    variant="outline"
-                    size="sm"
-                    className="flex-1 gap-1.5"
-                    onClick={() => handleAddToQueue('next')}
-                  >
-                    <span className="material-icons-outlined text-base">playlist_play</span>
-                    Play Next
-                  </Button>
-                  <Button
-                    variant="outline"
-                    size="sm"
-                    className="flex-1 gap-1.5"
-                    onClick={() => handleAddToQueue('end')}
-                  >
-                    <span className="material-icons-outlined text-base">playlist_add</span>
-                    Add to Queue
-                  </Button>
-                </div>
               </div>
             </div>
           )}

+ 202 - 26
frontend/src/pages/LEDPage.tsx

@@ -26,12 +26,21 @@ import { ColorPicker } from '@/components/ui/color-picker'
 
 // Types
 interface LedConfig {
-  provider: 'none' | 'wled' | 'dw_leds'
+  provider: 'none' | 'wled' | 'board'
   wled_ip?: string
   num_leds?: number
   gpio_pin?: number
 }
 
+interface BallParams {
+  fgbright: number
+  bgbright: number
+  size: number
+  bg: string
+  direction: 'cw' | 'ccw'
+  align: number
+}
+
 interface DWLedsStatus {
   connected: boolean
   power_on: boolean
@@ -43,6 +52,7 @@ interface DWLedsStatus {
   num_leds: number
   gpio_pin: number
   colors: string[]
+  ball?: BallParams
   error?: string
 }
 
@@ -63,6 +73,7 @@ export function LEDPage() {
   // DW LEDs state
   const [dwStatus, setDwStatus] = useState<DWLedsStatus | null>(null)
   const [effects, setEffects] = useState<[number, string][]>([])
+  const [effectNames, setEffectNames] = useState<[number, string][]>([])
   const [palettes, setPalettes] = useState<[number, string][]>([])
   const [brightness, setBrightness] = useState(35)
   const [speed, setSpeed] = useState(128)
@@ -75,6 +86,14 @@ export function LEDPage() {
   const [color2, setColor2] = useState('#000000')
   const [color3, setColor3] = useState('#0000ff')
 
+  // Ball tracker (firmware-native 'ball' effect) params
+  const [ballFgBright, setBallFgBright] = useState(255)
+  const [ballBgBright, setBallBgBright] = useState(255)
+  const [ballSize, setBallSize] = useState(3)
+  const [ballBg, setBallBg] = useState('static')
+  const [ballDirection, setBallDirection] = useState<'cw' | 'ccw'>('cw')
+  const [ballAlign, setBallAlign] = useState(0)
+
   // Ref for debouncing color picker API calls
   const colorDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
 
@@ -115,13 +134,13 @@ export function LEDPage() {
     fetchConfig()
   }, [])
 
-  // Initialize DW LEDs when provider is dw_leds
+  // Initialize the control panel for the board provider (the table's own ring
+  // via the firmware). The /api/dw_leds/* endpoints are the shared LED contract.
   useEffect(() => {
-    if (ledConfig?.provider === 'dw_leds') {
+    if (ledConfig?.provider === 'board') {
       fetchDWLedsStatus()
       fetchEffectsAndPalettes()
       fetchEffectSettings()
-      fetchIdleTimeout()
     }
   }, [ledConfig])
 
@@ -142,16 +161,33 @@ export function LEDPage() {
           setColor2(data.colors[1] || '#000000')
           setColor3(data.colors[2] || '#0000ff')
         }
+        if (data.ball) {
+          setBallFgBright(data.ball.fgbright ?? 255)
+          setBallBgBright(data.ball.bgbright ?? 255)
+          setBallSize(data.ball.size ?? 3)
+          setBallBg(data.ball.bg || 'static')
+          setBallDirection(data.ball.direction === 'ccw' ? 'ccw' : 'cw')
+          setBallAlign(data.ball.align ?? 0)
+        }
       }
     } catch (error) {
       console.error('Error fetching DW LEDs status:', error)
     }
   }
 
+  // Push one or more ball-tracker params to the firmware (/sand_led).
+  const sendBall = async (params: Record<string, number | string>) => {
+    try {
+      await apiClient.post('/api/dw_leds/ball', params)
+    } catch {
+      toast.error('Failed to update ball tracker')
+    }
+  }
+
   const fetchEffectsAndPalettes = async () => {
     try {
       const [effectsData, palettesData] = await Promise.all([
-        apiClient.get<{ effects?: [number, string][] }>('/api/dw_leds/effects'),
+        apiClient.get<{ effects?: [number, string][]; names?: [number, string][] }>('/api/dw_leds/effects'),
         apiClient.get<{ palettes?: [number, string][] }>('/api/dw_leds/palettes'),
       ])
 
@@ -159,6 +195,9 @@ export function LEDPage() {
         const sorted = [...effectsData.effects].sort((a, b) => a[1].localeCompare(b[1]))
         setEffects(sorted)
       }
+      if (effectsData.names) {
+        setEffectNames(effectsData.names)
+      }
       if (palettesData.palettes) {
         const sorted = [...palettesData.palettes].sort((a, b) => a[1].localeCompare(b[1]))
         setPalettes(sorted)
@@ -178,17 +217,6 @@ export function LEDPage() {
     }
   }
 
-  const fetchIdleTimeout = async () => {
-    try {
-      const data = await apiClient.get<{ enabled?: boolean; minutes?: number }>('/api/dw_leds/idle_timeout')
-      setIdleTimeoutEnabled(data.enabled || false)
-      setIdleTimeoutMinutes(data.minutes || 30)
-      setIdleTimeoutInput(String(data.minutes || 30))
-    } catch (error) {
-      console.error('Error fetching idle timeout:', error)
-    }
-  }
-
   const handleControlModeChange = async (mode: 'manual' | 'automated') => {
     setControlMode(mode)
     try {
@@ -365,6 +393,9 @@ export function LEDPage() {
   const formatEffectSettings = (settings: EffectSettings | null) => {
     if (!settings) return 'Not configured'
     const effectName = effects.find((e) => e[0] === settings.effect_id)?.[1] || settings.effect_id
+    // Board-provider automation stores only the effect name (the firmware keeps
+    // its current palette/speed/colors); other fields come back null.
+    if (settings.palette_id == null) return String(effectName)
     const paletteName = palettes.find((p) => p[0] === settings.palette_id)?.[1] || settings.palette_id
     return `${effectName} | ${paletteName} | Speed: ${settings.speed} | Intensity: ${settings.intensity}`
   }
@@ -392,7 +423,7 @@ export function LEDPage() {
         <div className="space-y-2">
           <h1 className="text-xl sm:text-2xl font-bold">LED Controller Not Configured</h1>
           <p className="text-sm sm:text-base text-muted-foreground max-w-md">
-            Configure your LED controller (WLED or DW LEDs) in the Settings page to control your lights.
+            Configure your LED controller (the table's built-in LEDs or WLED) in the Settings page to control your lights.
           </p>
         </div>
         <Button asChild className="gap-2">
@@ -472,18 +503,35 @@ export function LEDPage() {
     )
   }
 
-  // DW LEDs control panel
+  // LED control panel (board = the table's ring via firmware; dw_leds = legacy host GPIO)
+  const isBoard = ledConfig.provider === 'board'
+  // The firmware-native 'ball' tracker: detected by the selected effect's label.
+  const isBallEffect = effects.find(([id]) => String(id) === selectedEffect)?.[1] === 'Ball'
+  // Background sub-effect choices for the ball (firmware effect names, + static/off).
+  const ballBgOptions = [
+    { value: 'static', label: 'Solid color' },
+    { value: 'off', label: 'Off (black)' },
+    ...effectNames
+      .filter(([, name]) => !['ball', 'off', 'static'].includes(name))
+      .map(([id, name]) => ({
+        value: name,
+        label: effects.find(([eid]) => eid === id)?.[1] || name,
+      }))
+      .sort((a, b) => a.label.localeCompare(b.label)),
+  ]
   return (
     <div className="flex flex-col w-full max-w-5xl mx-auto gap-6 py-3 sm:py-6 px-0 sm:px-4">
       {/* Page Header */}
       <div className="space-y-0.5 sm:space-y-1 pl-1">
         <h1 className="text-xl font-semibold tracking-tight">LED Control</h1>
-        <p className="text-xs text-muted-foreground">DW LEDs - GPIO controlled LED strip</p>
+        <p className="text-xs text-muted-foreground">
+          {isBoard ? "Table's built-in LED ring, controlled by the firmware" : 'DW LEDs - GPIO controlled LED strip'}
+        </p>
       </div>
 
       <Separator />
 
-      {modeSelector}
+      {!isBoard && modeSelector}
 
       {/* Main Control Grid - 2 columns on large screens */}
       <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
@@ -520,7 +568,7 @@ export function LEDPage() {
                       {dwStatus?.connected ? 'check_circle' : 'error'}
                     </span>
                     {dwStatus?.connected
-                      ? `${dwStatus.num_leds} LEDs on GPIO ${dwStatus.gpio_pin}`
+                      ? (isBoard ? 'Table LEDs connected' : `${dwStatus.num_leds} LEDs on GPIO ${dwStatus.gpio_pin}`)
                       : 'Not connected'}
                   </div>
 
@@ -630,6 +678,8 @@ export function LEDPage() {
                     step={1}
                   />
                 </div>
+                {/* The firmware has no intensity control — dw_leds only */}
+                {!isBoard && (
                 <div className="p-4 rounded-lg border space-y-3">
                   <div className="flex justify-between items-center">
                     <Label>
@@ -669,9 +719,127 @@ export function LEDPage() {
                     step={1}
                   />
                 </div>
+                )}
               </div>
             </CardContent>
           </Card>
+
+          {/* Ball Tracker Card — firmware-native effect that follows the sand
+              ball. Shown when the 'Ball' effect is selected on the board. */}
+          {isBoard && isBallEffect && (
+            <Card>
+              <CardHeader className="pb-3">
+                <CardTitle className="text-lg flex items-center gap-2">
+                  <span className="material-icons-outlined text-muted-foreground">sports_baseball</span>
+                  Ball Tracker
+                </CardTitle>
+                <CardDescription>A glowing dot that follows the sand ball around the ring.</CardDescription>
+              </CardHeader>
+              <CardContent className="space-y-6">
+                {/* Blob controls */}
+                <div className="space-y-4">
+                  <p className="text-sm font-medium text-muted-foreground">Blob (the dot that tracks the ball)</p>
+                  <div className="flex items-center justify-between gap-4">
+                    <Label>Blob color</Label>
+                    <ColorPicker value={color1} onChange={(color) => handleColorChange(1, color)} />
+                  </div>
+                  <div className="p-4 rounded-lg border space-y-3">
+                    <div className="flex justify-between items-center">
+                      <Label>Blob brightness</Label>
+                      <span className="text-sm font-medium">{ballFgBright}</span>
+                    </div>
+                    <Slider
+                      value={[ballFgBright]}
+                      onValueChange={(v) => setBallFgBright(v[0])}
+                      onValueCommit={(v) => { setBallFgBright(v[0]); sendBall({ fgbright: v[0] }) }}
+                      max={255}
+                      step={1}
+                    />
+                  </div>
+                  <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
+                    <div className="space-y-2">
+                      <Label>Direction</Label>
+                      <Select
+                        value={ballDirection}
+                        onValueChange={(v) => { const d = v as 'cw' | 'ccw'; setBallDirection(d); sendBall({ direction: d }) }}
+                      >
+                        <SelectTrigger><SelectValue /></SelectTrigger>
+                        <SelectContent>
+                          <SelectItem value="cw">Clockwise</SelectItem>
+                          <SelectItem value="ccw">Counter-clockwise</SelectItem>
+                        </SelectContent>
+                      </Select>
+                    </div>
+                    <div className="p-4 rounded-lg border space-y-3">
+                      <div className="flex justify-between items-center">
+                        <Label>Glow size</Label>
+                        <span className="text-sm font-medium">{ballSize}</span>
+                      </div>
+                      <Slider
+                        value={[ballSize]}
+                        onValueChange={(v) => setBallSize(v[0])}
+                        onValueCommit={(v) => { setBallSize(v[0]); sendBall({ size: v[0] }) }}
+                        min={1}
+                        max={30}
+                        step={1}
+                      />
+                    </div>
+                  </div>
+                  <div className="p-4 rounded-lg border space-y-3">
+                    <div className="flex justify-between items-center">
+                      <Label>Alignment (°)</Label>
+                      <span className="text-sm font-medium">{ballAlign}</span>
+                    </div>
+                    <Slider
+                      value={[ballAlign]}
+                      onValueChange={(v) => setBallAlign(v[0])}
+                      onValueCommit={(v) => { setBallAlign(v[0]); sendBall({ align: v[0] }) }}
+                      max={359}
+                      step={1}
+                    />
+                    <p className="text-xs text-muted-foreground">Rotate the blob so it sits on the ball.</p>
+                  </div>
+                </div>
+
+                {/* Background controls */}
+                <div className="space-y-4 pt-2 border-t">
+                  <p className="text-sm font-medium text-muted-foreground">Background (behind the blob)</p>
+                  <div className="space-y-2">
+                    <Label>Background</Label>
+                    <Select value={ballBg} onValueChange={(v) => { setBallBg(v); sendBall({ bg: v }) }}>
+                      <SelectTrigger><SelectValue /></SelectTrigger>
+                      <SelectContent>
+                        {ballBgOptions.map((o) => (
+                          <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
+                        ))}
+                      </SelectContent>
+                    </Select>
+                  </div>
+                  {ballBg === 'static' && (
+                    <div className="flex items-center justify-between gap-4">
+                      <Label>Background color</Label>
+                      <ColorPicker value={color2} onChange={(color) => handleColorChange(2, color)} />
+                    </div>
+                  )}
+                  {ballBg !== 'off' && (
+                    <div className="p-4 rounded-lg border space-y-3">
+                      <div className="flex justify-between items-center">
+                        <Label>Background brightness</Label>
+                        <span className="text-sm font-medium">{ballBgBright}</span>
+                      </div>
+                      <Slider
+                        value={[ballBgBright]}
+                        onValueChange={(v) => setBallBgBright(v[0])}
+                        onValueCommit={(v) => { setBallBgBright(v[0]); sendBall({ bgbright: v[0] }) }}
+                        max={255}
+                        step={1}
+                      />
+                    </div>
+                  )}
+                </div>
+              </CardContent>
+            </Card>
+          )}
         </div>
 
         {/* Right Column - Colors & Quick Settings */}
@@ -700,6 +868,8 @@ export function LEDPage() {
                   />
                   <span className="text-xs text-muted-foreground">Secondary</span>
                 </div>
+                {/* The firmware only has two colors (Color/Color2) — dw_leds only */}
+                {!isBoard && (
                 <div className="flex flex-col items-center gap-2">
                   <ColorPicker
                     value={color3}
@@ -707,12 +877,14 @@ export function LEDPage() {
                   />
                   <span className="text-xs text-muted-foreground">Accent</span>
                 </div>
+                )}
               </div>
             </CardContent>
           </Card>
 
-          {/* Auto Turn Off - hidden in manual mode */}
-          {controlMode === 'automated' && (
+          {/* Auto Turn Off - host-side idle timeout, dw_leds only (the board's
+              own Still Sands / IdleEffect cover this for built-in LEDs) */}
+          {!isBoard && controlMode === 'automated' && (
           <Card className="flex-1 flex flex-col">
             <CardHeader className="pb-3">
               <CardTitle className="text-lg flex items-center gap-2">
@@ -765,8 +937,10 @@ export function LEDPage() {
         </div>
       </div>
 
-      {/* Automation Settings - Full Width - hidden in manual mode */}
-      {controlMode === 'automated' && (
+      {/* Automation Settings - Full Width - hidden in manual mode.
+          For board LEDs the switching happens on the table itself
+          ($LED/RunEffect / $LED/IdleEffect), so it's always available. */}
+      {(isBoard || controlMode === 'automated') && (
       <Card>
         <CardHeader className="pb-3">
           <CardTitle className="text-lg flex items-center gap-2">
@@ -774,7 +948,9 @@ export function LEDPage() {
             Effect Automation
           </CardTitle>
           <CardDescription>
-            Save current settings to automatically apply when table state changes
+            {isBoard
+              ? 'The table switches to these effects by itself while drawing and at rest'
+              : 'Save current settings to automatically apply when table state changes'}
           </CardDescription>
         </CardHeader>
         <CardContent>

+ 2289 - 2526
frontend/src/pages/SettingsPage.tsx

@@ -1,2526 +1,2289 @@
-import { useState, useEffect } from 'react'
-import { useSearchParams, useNavigate, Link } from 'react-router-dom'
-import { toast } from 'sonner'
-import { apiClient } from '@/lib/apiClient'
-import { useOnBackendConnected } from '@/hooks/useBackendConnection'
-import { Button } from '@/components/ui/button'
-import { Input } from '@/components/ui/input'
-import { Label } from '@/components/ui/label'
-import { Separator } from '@/components/ui/separator'
-import { Switch } from '@/components/ui/switch'
-import { Alert, AlertDescription } from '@/components/ui/alert'
-import {
-  Accordion,
-  AccordionContent,
-  AccordionItem,
-  AccordionTrigger,
-} from '@/components/ui/accordion'
-import {
-  Select,
-  SelectContent,
-  SelectGroup,
-  SelectItem,
-  SelectLabel,
-  SelectTrigger,
-  SelectValue,
-} from '@/components/ui/select'
-import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
-import { SearchableSelect } from '@/components/ui/searchable-select'
-import { UpdateDialog } from '@/components/UpdateDialog'
-
-// Types
-
-interface Settings {
-  app_name?: string
-  custom_logo?: string
-  preferred_port?: string
-  // Machine settings
-  table_type_override?: string
-  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 }[]
-  // Homing settings
-  homing_mode?: number
-  angular_offset?: number
-  home_on_connect?: boolean
-  auto_home_enabled?: boolean
-  auto_home_after_patterns?: number
-  hard_reset_theta?: boolean
-  // Pattern clearing settings
-  clear_pattern_speed?: number
-  custom_clear_from_in?: string
-  custom_clear_from_out?: string
-}
-
-interface TimeSlot {
-  start_time: string
-  end_time: string
-  days: 'daily' | 'weekdays' | 'weekends' | 'custom'
-  custom_days?: string[]
-}
-
-interface StillSandsSettings {
-  enabled: boolean
-  finish_pattern: boolean
-  control_wled: boolean
-  timezone: string
-  time_slots: TimeSlot[]
-}
-
-interface AutoPlaySettings {
-  enabled: boolean
-  playlist: string
-  run_mode: 'single' | 'loop'
-  pause_time: number
-  clear_pattern: string
-  shuffle: boolean
-}
-
-interface LedConfig {
-  provider: 'none' | 'wled' | 'dw_leds'
-  wled_ip?: string
-  num_leds?: number
-  gpio_pin?: number
-  pixel_order?: string
-}
-
-interface MqttConfig {
-  enabled: boolean
-  broker?: string
-  port?: number
-  username?: string
-  password?: string
-  device_name?: string
-  device_id?: string
-  client_id?: string
-  discovery_prefix?: string
-}
-
-export function SettingsPage() {
-  const [searchParams, setSearchParams] = useSearchParams()
-  const navigate = useNavigate()
-  const sectionParam = searchParams.get('section')
-
-  // Connection state
-  const [ports, setPorts] = useState<string[]>([])
-  const [selectedPort, setSelectedPort] = useState('')
-  const [isConnected, setIsConnected] = useState(false)
-  const [connectionStatus, setConnectionStatus] = useState('Disconnected')
-
-  // Settings state
-  const [settings, setSettings] = useState<Settings>({})
-  const [ledConfig, setLedConfig] = useState<LedConfig>({ provider: 'none', gpio_pin: 18 })
-  const [numLedsInput, setNumLedsInput] = useState('60')
-  const [mqttConfig, setMqttConfig] = useState<MqttConfig>({ enabled: false })
-
-  // UI state
-  const [isLoading, setIsLoading] = useState<string | null>(null)
-
-  // Accordion state - controlled by URL params
-  const [openSections, setOpenSections] = useState<string[]>(() => {
-    if (sectionParam) return [sectionParam]
-    return ['connection']
-  })
-
-  // Track which sections have been loaded (for lazy loading)
-  const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set())
-
-  // Auto-play state
-  const [autoPlaySettings, setAutoPlaySettings] = useState<AutoPlaySettings>({
-    enabled: false,
-    playlist: '',
-    run_mode: 'loop',
-    pause_time: 5,
-    clear_pattern: 'adaptive',
-    shuffle: false,
-  })
-  const [autoPlayPauseUnit, setAutoPlayPauseUnit] = useState<'sec' | 'min' | 'hr'>('min')
-  const [autoPlayPauseValue, setAutoPlayPauseValue] = useState(5)
-  const [autoPlayPauseInput, setAutoPlayPauseInput] = useState('5')
-  const [playlists, setPlaylists] = useState<string[]>([])
-
-  // Convert pause time from seconds to value + unit for display
-  const secondsToDisplayPause = (seconds: number): { value: number; unit: 'sec' | 'min' | 'hr' } => {
-    if (seconds >= 3600 && seconds % 3600 === 0) {
-      return { value: seconds / 3600, unit: 'hr' }
-    } else if (seconds >= 60 && seconds % 60 === 0) {
-      return { value: seconds / 60, unit: 'min' }
-    }
-    return { value: seconds, unit: 'sec' }
-  }
-
-  // Convert display value + unit to seconds
-  const displayPauseToSeconds = (value: number, unit: 'sec' | 'min' | 'hr'): number => {
-    switch (unit) {
-      case 'hr': return value * 3600
-      case 'min': return value * 60
-      default: return value
-    }
-  }
-
-  // Still Sands state
-  const [stillSandsSettings, setStillSandsSettings] = useState<StillSandsSettings>({
-    enabled: false,
-    finish_pattern: false,
-    control_wled: false,
-    timezone: '',
-    time_slots: [],
-  })
-
-  // Pattern search state for clearing patterns
-  const [patternFiles, setPatternFiles] = useState<string[]>([])
-
-  // Security state
-  const [securityMode, setSecurityMode] = useState<'off' | 'lockdown' | 'play_only'>('off')
-  const [securityPassword, setSecurityPassword] = useState('')
-  const [securityPasswordConfirm, setSecurityPasswordConfirm] = useState('')
-  const [hasExistingPassword, setHasExistingPassword] = useState(false)
-
-  // Version state
-  const [versionInfo, setVersionInfo] = useState<{
-    current: string
-    latest: string
-    update_available: boolean
-  } | null>(null)
-  const [updateDialogOpen, setUpdateDialogOpen] = useState(false)
-
-  // Helper to scroll to element with header offset
-  const scrollToSection = (sectionId: string) => {
-    const element = document.getElementById(`section-${sectionId}`)
-    if (element) {
-      const headerHeight = 80 // Header height + some padding
-      const elementTop = element.getBoundingClientRect().top + window.scrollY
-      window.scrollTo({ top: elementTop - headerHeight, behavior: 'smooth' })
-    }
-  }
-
-  // Scroll to section and clear URL param after navigation
-  useEffect(() => {
-    if (sectionParam) {
-      // Scroll to the section after a short delay to allow render
-      setTimeout(() => {
-        scrollToSection(sectionParam)
-        // Clear the search param from URL
-        setSearchParams({}, { replace: true })
-      }, 100)
-    }
-  }, [sectionParam, setSearchParams])
-
-  // Load section data when expanded (lazy loading)
-  const loadSectionData = async (section: string) => {
-    if (loadedSections.has(section)) return
-
-    setLoadedSections((prev) => new Set(prev).add(section))
-
-    switch (section) {
-      case 'connection':
-        await fetchPorts()
-        // Also load settings for preferred port
-        if (!loadedSections.has('_settings')) {
-          setLoadedSections((prev) => new Set(prev).add('_settings'))
-          await fetchSettings()
-        }
-        break
-      case 'application':
-      case 'mqtt':
-      case 'autoplay':
-      case 'stillsands':
-      case 'machine':
-      case 'homing':
-      case 'clearing':
-      case 'security':
-        // These all share settings data
-        if (!loadedSections.has('_settings')) {
-          setLoadedSections((prev) => new Set(prev).add('_settings'))
-          await fetchSettings()
-        }
-        if ((section === 'autoplay' || section === 'clearing') && !loadedSections.has('_playlists')) {
-          setLoadedSections((prev) => new Set(prev).add('_playlists'))
-          await fetchPlaylists()
-        }
-        if (section === 'clearing' && !loadedSections.has('_patterns')) {
-          setLoadedSections((prev) => new Set(prev).add('_patterns'))
-          await fetchPatternFiles()
-        }
-        break
-      case 'led':
-        await fetchLedConfig()
-        break
-      case 'version':
-        await fetchVersionInfo()
-        break
-    }
-  }
-
-  const fetchPatternFiles = async () => {
-    try {
-      const data = await apiClient.get<string[]>('/list_theta_rho_files')
-      // Response is a flat array of file paths
-      setPatternFiles(Array.isArray(data) ? data : [])
-    } catch (error) {
-      console.error('Error fetching pattern files:', error)
-    }
-  }
-
-  const fetchVersionInfo = async () => {
-    try {
-      const data = await apiClient.get<{ current: string; latest: string; update_available: boolean }>('/api/version')
-      setVersionInfo(data)
-    } catch (error) {
-      console.error('Failed to fetch version info:', error)
-    }
-  }
-
-  // Handle accordion open/close and trigger data loading
-  const handleAccordionChange = (values: string[]) => {
-    // Find newly opened section
-    const newlyOpened = values.find((v) => !openSections.includes(v))
-
-    setOpenSections(values)
-
-    // Load data for newly opened sections
-    values.forEach((section) => {
-      if (!loadedSections.has(section)) {
-        loadSectionData(section)
-      }
-    })
-
-    // Scroll newly opened section into view
-    if (newlyOpened) {
-      setTimeout(() => {
-        scrollToSection(newlyOpened)
-      }, 100)
-    }
-  }
-
-  // Load initial section data
-  useEffect(() => {
-    openSections.forEach((section) => {
-      loadSectionData(section)
-    })
-  }, [])
-
-  const fetchPorts = async () => {
-    try {
-      // Fetch available ports first
-      const portsData = await apiClient.get<string[]>('/list_serial_ports')
-      const availablePorts = portsData || []
-      setPorts(availablePorts)
-
-      // Fetch connection status
-      const statusData = await apiClient.get<{ connected: boolean; port?: string }>('/serial_status')
-      setIsConnected(statusData.connected || false)
-      setConnectionStatus(statusData.connected ? 'Connected' : 'Disconnected')
-
-      // Only set selectedPort if it exists in the available ports list
-      // This prevents race conditions where stale port data from a different
-      // backend (e.g., Mac port on a Pi) could be set
-      if (statusData.port && availablePorts.includes(statusData.port)) {
-        setSelectedPort(statusData.port)
-      } else if (statusData.port && !availablePorts.includes(statusData.port)) {
-        // Port from status doesn't exist on this machine - likely stale data
-        console.warn(`Port ${statusData.port} from status not in available ports, ignoring`)
-        setSelectedPort('')
-      }
-    } catch (error) {
-      console.error('Error fetching ports:', error)
-    }
-  }
-
-  // Always fetch ports on mount since connection is the default section
-  useEffect(() => {
-    fetchPorts()
-  }, [])
-
-  // Refetch when backend reconnects
-  useOnBackendConnected(() => {
-    fetchPorts()
-  })
-
-  const fetchSettings = async () => {
-    try {
-      // eslint-disable-next-line @typescript-eslint/no-explicit-any
-      const data = await apiClient.get<Record<string, any>>('/api/settings')
-      // Map the nested API response to our flat Settings interface
-      setSettings({
-        app_name: data.app?.name,
-        custom_logo: data.app?.custom_logo,
-        preferred_port: data.connection?.preferred_port,
-        // Machine settings
-        table_type_override: data.machine?.table_type_override,
-        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,
-        // Homing settings
-        homing_mode: data.homing?.mode,
-        angular_offset: data.homing?.angular_offset_degrees,
-        home_on_connect: data.homing?.home_on_connect,
-        auto_home_enabled: data.homing?.auto_home_enabled,
-        auto_home_after_patterns: data.homing?.auto_home_after_patterns,
-        hard_reset_theta: data.homing?.hard_reset_theta,
-        // Pattern clearing settings
-        clear_pattern_speed: data.patterns?.clear_pattern_speed,
-        custom_clear_from_in: data.patterns?.custom_clear_from_in,
-        custom_clear_from_out: data.patterns?.custom_clear_from_out,
-      })
-      // Set auto-play settings
-      if (data.auto_play) {
-        const pauseSeconds = data.auto_play.pause_time ?? 300 // Default 5 minutes
-        const { value, unit } = secondsToDisplayPause(pauseSeconds)
-        setAutoPlayPauseValue(value)
-        setAutoPlayPauseInput(String(value))
-        setAutoPlayPauseUnit(unit)
-        setAutoPlaySettings({
-          enabled: data.auto_play.enabled || false,
-          playlist: data.auto_play.playlist || '',
-          run_mode: data.auto_play.run_mode || 'loop',
-          pause_time: pauseSeconds,
-          clear_pattern: data.auto_play.clear_pattern || 'adaptive',
-          shuffle: data.auto_play.shuffle || false,
-        })
-      }
-      // Set still sands settings
-      if (data.scheduled_pause) {
-        setStillSandsSettings({
-          enabled: data.scheduled_pause.enabled || false,
-          finish_pattern: data.scheduled_pause.finish_pattern || false,
-          control_wled: data.scheduled_pause.control_wled || false,
-          timezone: data.scheduled_pause.timezone || '',
-          time_slots: data.scheduled_pause.time_slots || [],
-        })
-      }
-      // Set security settings
-      if (data.security) {
-        setSecurityMode(data.security.mode || 'off')
-        setHasExistingPassword(data.security.has_password || false)
-      }
-      // Set MQTT config from the same response
-      if (data.mqtt) {
-        setMqttConfig({
-          enabled: data.mqtt.enabled || false,
-          broker: data.mqtt.broker,
-          port: data.mqtt.port,
-          username: data.mqtt.username,
-          device_name: data.mqtt.device_name,
-          device_id: data.mqtt.device_id,
-          client_id: data.mqtt.client_id,
-          discovery_prefix: data.mqtt.discovery_prefix,
-        })
-      }
-    } catch (error) {
-      console.error('Error fetching settings:', error)
-    }
-  }
-
-  const fetchLedConfig = async () => {
-    try {
-      // eslint-disable-next-line @typescript-eslint/no-explicit-any
-      const data = await apiClient.get<Record<string, any>>('/get_led_config')
-      setLedConfig({
-        provider: data.provider || 'none',
-        wled_ip: data.wled_ip,
-        num_leds: data.dw_led_num_leds,
-        gpio_pin: data.dw_led_gpio_pin,
-        pixel_order: data.dw_led_pixel_order,
-      })
-      setNumLedsInput(String(data.dw_led_num_leds || 60))
-    } catch (error) {
-      console.error('Error fetching LED config:', error)
-    }
-  }
-
-  const fetchPlaylists = async () => {
-    try {
-      const data = await apiClient.get('/list_all_playlists')
-      // Backend returns array directly, not { playlists: [...] }
-      setPlaylists(Array.isArray(data) ? data : [])
-    } catch (error) {
-      console.error('Error fetching playlists:', error)
-    }
-  }
-
-  const handleConnect = async () => {
-    if (!selectedPort) {
-      toast.error('Please select a port')
-      return
-    }
-    setIsLoading('connect')
-    try {
-      const data = await apiClient.post<{ success?: boolean; message?: string }>('/connect', { port: selectedPort })
-      if (data.success) {
-        setIsConnected(true)
-        setConnectionStatus(`Connected to ${selectedPort}`)
-        toast.success('Connected successfully')
-      } else {
-        throw new Error(data.message || 'Connection failed')
-      }
-    } catch (error) {
-      toast.error('Failed to connect')
-    } finally {
-      setIsLoading(null)
-    }
-  }
-
-  const handleDisconnect = async () => {
-    setIsLoading('disconnect')
-    try {
-      const data = await apiClient.post<{ success?: boolean }>('/disconnect')
-      if (data.success) {
-        setIsConnected(false)
-        setConnectionStatus('Disconnected')
-        toast.success('Disconnected')
-      }
-    } catch (error) {
-      toast.error('Failed to disconnect')
-    } finally {
-      setIsLoading(null)
-    }
-  }
-
-  const handleSavePreferredPort = async () => {
-    setIsLoading('preferredPort')
-    try {
-      // Send the actual value: __auto__, __none__, or specific port
-      const portValue = settings.preferred_port || '__auto__'
-      await apiClient.patch('/api/settings', {
-        connection: { preferred_port: portValue },
-      })
-      if (!settings.preferred_port || settings.preferred_port === '__auto__') {
-        toast.success('Auto-connect: Auto (first available port)')
-      } else if (settings.preferred_port === '__none__') {
-        toast.success('Auto-connect: Disabled')
-      } else {
-        toast.success(`Auto-connect: ${settings.preferred_port}`)
-      }
-    } catch (error) {
-      toast.error('Failed to save auto-connect setting')
-    } finally {
-      setIsLoading(null)
-    }
-  }
-
-  const handleSaveAppName = async () => {
-    setIsLoading('appName')
-    try {
-      await apiClient.patch('/api/settings', { app: { name: settings.app_name } })
-      toast.success('App name saved. Refresh to see changes.')
-    } catch (error) {
-      toast.error('Failed to save app name')
-    } finally {
-      setIsLoading(null)
-    }
-  }
-
-  // Update favicon links in the document head and notify Layout to refresh
-  const updateBranding = (customLogo: string | null) => {
-    const timestamp = Date.now() // Cache buster
-
-    // Update favicon links (use apiClient.getAssetUrl for multi-table support)
-    const faviconIco = document.getElementById('favicon-ico') as HTMLLinkElement
-    const appleTouchIcon = document.getElementById('apple-touch-icon') as HTMLLinkElement
-
-    if (customLogo) {
-      if (faviconIco) faviconIco.href = apiClient.getAssetUrl(`/static/custom/favicon.ico?v=${timestamp}`)
-      if (appleTouchIcon) appleTouchIcon.href = apiClient.getAssetUrl(`/static/custom/${customLogo}?v=${timestamp}`)
-    } else {
-      if (faviconIco) faviconIco.href = apiClient.getAssetUrl(`/static/favicon.ico?v=${timestamp}`)
-      if (appleTouchIcon) appleTouchIcon.href = apiClient.getAssetUrl(`/static/apple-touch-icon.png?v=${timestamp}`)
-    }
-
-    // Dispatch event for Layout to update header logo
-    window.dispatchEvent(new CustomEvent('branding-updated'))
-  }
-
-  const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
-    const file = e.target.files?.[0]
-    if (!file) return
-
-    setIsLoading('logo')
-    try {
-      const data = await apiClient.uploadFile('/api/upload-logo', file, 'file') as { filename: string }
-      setSettings({ ...settings, custom_logo: data.filename })
-      updateBranding(data.filename)
-      toast.success('Logo uploaded!')
-    } catch (error) {
-      toast.error(error instanceof Error ? error.message : 'Failed to upload logo')
-    } finally {
-      setIsLoading(null)
-      // Reset the input
-      e.target.value = ''
-    }
-  }
-
-  const handleDeleteLogo = async () => {
-    if (!confirm('Remove custom logo and revert to default?')) return
-
-    setIsLoading('logo')
-    try {
-      await apiClient.delete('/api/custom-logo')
-      setSettings({ ...settings, custom_logo: undefined })
-      updateBranding(null)
-      toast.success('Logo removed!')
-    } catch (error) {
-      toast.error('Failed to remove logo')
-    } finally {
-      setIsLoading(null)
-    }
-  }
-
-  const handleSaveLedConfig = async () => {
-    setIsLoading('led')
-    try {
-      // Use the /set_led_config endpoint (deprecated but still works)
-      await apiClient.post('/set_led_config', {
-        provider: ledConfig.provider,
-        ip_address: ledConfig.wled_ip,
-        num_leds: ledConfig.num_leds,
-        gpio_pin: ledConfig.gpio_pin,
-        pixel_order: ledConfig.pixel_order,
-      })
-      toast.success('LED configuration saved')
-    } catch (error) {
-      toast.error(error instanceof Error ? error.message : 'Failed to save LED config')
-    } finally {
-      setIsLoading(null)
-    }
-  }
-
-  const handleSaveMqttConfig = async () => {
-    setIsLoading('mqtt')
-    try {
-      await apiClient.patch('/api/settings', {
-        mqtt: {
-          enabled: mqttConfig.enabled,
-          broker: mqttConfig.broker,
-          port: mqttConfig.port,
-          username: mqttConfig.username,
-          password: mqttConfig.password,
-          device_name: mqttConfig.device_name,
-          device_id: mqttConfig.device_id,
-          client_id: mqttConfig.client_id,
-          discovery_prefix: mqttConfig.discovery_prefix,
-        },
-      })
-      toast.success('MQTT configuration saved. Restart required.')
-    } catch (error) {
-      toast.error('Failed to save MQTT config')
-    } finally {
-      setIsLoading(null)
-    }
-  }
-
-  const handleTestMqttConnection = async () => {
-    if (!mqttConfig.broker) {
-      toast.error('Please enter a broker address')
-      return
-    }
-    setIsLoading('mqttTest')
-    try {
-      const data = await apiClient.post<{ success?: boolean; error?: string }>('/api/mqtt-test', {
-        broker: mqttConfig.broker,
-        port: mqttConfig.port || 1883,
-        username: mqttConfig.username || '',
-        password: mqttConfig.password || '',
-      })
-      if (data.success) {
-        toast.success('MQTT connection successful!')
-      } else {
-        toast.error(data.error || 'Connection failed')
-      }
-    } catch (error) {
-      toast.error('Failed to test MQTT connection')
-    } finally {
-      setIsLoading(null)
-    }
-  }
-
-  const handleSaveMachineSettings = async () => {
-    setIsLoading('machine')
-    try {
-      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')
-    } catch (error) {
-      toast.error('Failed to save machine settings')
-    } finally {
-      setIsLoading(null)
-    }
-  }
-
-  const handleSaveHomingConfig = async () => {
-    setIsLoading('homing')
-    try {
-      await apiClient.patch('/api/settings', {
-        homing: {
-          mode: settings.homing_mode,
-          angular_offset_degrees: settings.angular_offset,
-          home_on_connect: settings.home_on_connect,
-          auto_home_enabled: settings.auto_home_enabled,
-          auto_home_after_patterns: settings.auto_home_after_patterns,
-          hard_reset_theta: settings.hard_reset_theta,
-        },
-      })
-      toast.success('Homing configuration saved')
-    } catch (error) {
-      toast.error('Failed to save homing configuration')
-    } finally {
-      setIsLoading(null)
-    }
-  }
-
-  const handleSaveClearingSettings = async () => {
-    setIsLoading('clearing')
-    try {
-      await apiClient.patch('/api/settings', {
-        patterns: {
-          // Send 0 to indicate "reset to default" - backend interprets 0 or negative as None
-          clear_pattern_speed: settings.clear_pattern_speed ?? 0,
-          custom_clear_from_in: settings.custom_clear_from_in || null,
-          custom_clear_from_out: settings.custom_clear_from_out || null,
-        },
-      })
-      toast.success('Clearing settings saved')
-    } catch (error) {
-      toast.error('Failed to save clearing settings')
-    } finally {
-      setIsLoading(null)
-    }
-  }
-
-  const handleSaveAutoPlaySettings = async () => {
-    setIsLoading('autoplay')
-    try {
-      // Convert pause value + unit to seconds
-      const pauseTimeSeconds = displayPauseToSeconds(autoPlayPauseValue, autoPlayPauseUnit)
-      await apiClient.patch('/api/settings', {
-        auto_play: {
-          ...autoPlaySettings,
-          pause_time: pauseTimeSeconds,
-        },
-      })
-      toast.success('Auto-play settings saved')
-    } catch (error) {
-      toast.error('Failed to save auto-play settings')
-    } finally {
-      setIsLoading(null)
-    }
-  }
-
-  const handleSaveStillSandsSettings = async () => {
-    setIsLoading('stillsands')
-    try {
-      await apiClient.patch('/api/settings', {
-        scheduled_pause: stillSandsSettings,
-      })
-      toast.success('Still Sands settings saved')
-    } catch (error) {
-      toast.error('Failed to save Still Sands settings')
-    } finally {
-      setIsLoading(null)
-    }
-  }
-
-  const addTimeSlot = () => {
-    setStillSandsSettings({
-      ...stillSandsSettings,
-      time_slots: [
-        ...stillSandsSettings.time_slots,
-        { start_time: '22:00', end_time: '06:00', days: 'daily', custom_days: [] },
-      ],
-    })
-  }
-
-  const removeTimeSlot = (index: number) => {
-    setStillSandsSettings({
-      ...stillSandsSettings,
-      time_slots: stillSandsSettings.time_slots.filter((_, i) => i !== index),
-    })
-  }
-
-  const updateTimeSlot = (index: number, updates: Partial<TimeSlot>) => {
-    const newSlots = [...stillSandsSettings.time_slots]
-    newSlots[index] = { ...newSlots[index], ...updates }
-    setStillSandsSettings({ ...stillSandsSettings, time_slots: newSlots })
-  }
-
-  return (
-    <div className="flex flex-col w-full max-w-5xl mx-auto gap-6 py-3 sm:py-6 px-0 sm:px-4">
-      {/* Page Header */}
-      <div className="space-y-0.5 sm:space-y-1 pl-1">
-        <h1 className="text-xl font-semibold tracking-tight">Settings</h1>
-        <p className="text-xs text-muted-foreground">
-          Configure your sand table
-        </p>
-      </div>
-
-      <Separator />
-
-      <Accordion
-        type="multiple"
-        value={openSections}
-        onValueChange={handleAccordionChange}
-        className="space-y-3"
-      >
-        {/* Device Connection */}
-        <AccordionItem value="connection" id="section-connection" className="border rounded-lg px-4 overflow-visible bg-card">
-          <AccordionTrigger className="hover:no-underline">
-            <div className="flex items-center gap-3">
-              <span className="material-icons-outlined text-muted-foreground">
-                usb
-              </span>
-              <div className="text-left">
-                <div className="font-semibold">Device Connection</div>
-                <div className="text-sm text-muted-foreground font-normal">
-                  Serial port configuration
-                </div>
-              </div>
-            </div>
-          </AccordionTrigger>
-          <AccordionContent className="pt-4 pb-6 space-y-6">
-            {/* Connection Status */}
-            <div className="flex items-center justify-between p-4 rounded-lg border">
-              <div className="flex items-center gap-3">
-                <div className={`w-10 h-10 flex items-center justify-center rounded-lg ${isConnected ? 'bg-green-100 dark:bg-green-900' : 'bg-muted'}`}>
-                  <span className={`material-icons ${isConnected ? 'text-green-600' : 'text-muted-foreground'}`}>
-                    {isConnected ? 'usb' : 'usb_off'}
-                  </span>
-                </div>
-                <div>
-                  <p className="font-medium">Status</p>
-                  <p className={`text-sm ${isConnected ? 'text-green-600' : 'text-destructive'}`}>
-                    {connectionStatus}
-                  </p>
-                </div>
-              </div>
-              {isConnected && (
-                <Button
-                  variant="destructive"
-                  size="sm"
-                  onClick={handleDisconnect}
-                  disabled={isLoading === 'disconnect'}
-                >
-                  Disconnect
-                </Button>
-              )}
-            </div>
-
-            {/* Port Selection */}
-            <div className="space-y-3">
-              <Label>Available Serial Ports</Label>
-              <div className="flex gap-3">
-                <Select value={selectedPort} onValueChange={setSelectedPort}>
-                  <SelectTrigger className="flex-1">
-                    <SelectValue placeholder="Select a port..." />
-                  </SelectTrigger>
-                  <SelectContent>
-                    {ports.length === 0 ? (
-                      <div className="py-6 text-center text-sm text-muted-foreground">
-                        No serial ports found
-                      </div>
-                    ) : (
-                      ports.map((port) => (
-                        <SelectItem key={port} value={port}>
-                          {port}
-                        </SelectItem>
-                      ))
-                    )}
-                  </SelectContent>
-                </Select>
-                <Button
-                  onClick={handleConnect}
-                  disabled={isLoading === 'connect' || !selectedPort || isConnected}
-                  className="gap-2"
-                >
-                  {isLoading === 'connect' ? (
-                    <span className="material-icons-outlined animate-spin">sync</span>
-                  ) : (
-                    <span className="material-icons-outlined">cable</span>
-                  )}
-                  Connect
-                </Button>
-              </div>
-              <p className="text-xs text-muted-foreground">
-                Select a port and click 'Connect' to establish a connection.
-              </p>
-            </div>
-
-            <Separator />
-
-            {/* Preferred Port for Auto-Connect */}
-            <div className="space-y-3">
-              <Label>Auto-Connect</Label>
-              <div className="flex gap-3">
-                <Select
-                  value={settings.preferred_port || '__auto__'}
-                  onValueChange={(value) =>
-                    setSettings({ ...settings, preferred_port: value === '__auto__' ? undefined : value })
-                  }
-                >
-                  <SelectTrigger className="flex-1">
-                    <SelectValue placeholder="Select auto-connect option..." />
-                  </SelectTrigger>
-                  <SelectContent>
-                    <SelectItem value="__auto__">Auto (pick first available)</SelectItem>
-                    <SelectItem value="__none__">Disabled (no auto-connect)</SelectItem>
-                    {ports.length > 0 && (
-                      <>
-                        <div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">Available Ports</div>
-                        {ports.map((port) => (
-                          <SelectItem key={port} value={port}>
-                            {port}
-                          </SelectItem>
-                        ))}
-                      </>
-                    )}
-                  </SelectContent>
-                </Select>
-                <Button
-                  onClick={handleSavePreferredPort}
-                  disabled={isLoading === 'preferredPort'}
-                  className="gap-2"
-                >
-                  {isLoading === 'preferredPort' ? (
-                    <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">
-                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>
-
-        {/* Machine Settings */}
-        <AccordionItem value="machine" id="section-machine" className="border rounded-lg px-4 overflow-visible bg-card">
-          <AccordionTrigger className="hover:no-underline">
-            <div className="flex items-center gap-3">
-              <span className="material-icons-outlined text-muted-foreground">
-                precision_manufacturing
-              </span>
-              <div className="text-left">
-                <div className="font-semibold">Machine Settings</div>
-                <div className="text-sm text-muted-foreground font-normal">
-                  Table type and hardware configuration
-                </div>
-              </div>
-            </div>
-          </AccordionTrigger>
-          <AccordionContent className="pt-4 pb-6 space-y-6">
-            {/* Hardware Parameters */}
-            <div className="grid grid-cols-2 md:grid-cols-4 gap-3">
-              <div className="p-3 rounded-lg bg-muted/50">
-                <p className="text-xs text-muted-foreground">Detected Type</p>
-                <p className="font-medium text-sm">{settings.detected_table_type || 'Unknown'}</p>
-              </div>
-              <div className="p-3 rounded-lg bg-muted/50">
-                <p className="text-xs text-muted-foreground">Gear Ratio</p>
-                <p className="font-medium text-sm">{settings.gear_ratio ?? '—'}</p>
-              </div>
-              <div className="p-3 rounded-lg bg-muted/50">
-                <p className="text-xs text-muted-foreground">X Steps/mm</p>
-                <p className="font-medium text-sm">{settings.x_steps_per_mm ?? '—'}</p>
-              </div>
-              <div className="p-3 rounded-lg bg-muted/50">
-                <p className="text-xs text-muted-foreground">Y Steps/mm</p>
-                <p className="font-medium text-sm">{settings.y_steps_per_mm ?? '—'}</p>
-              </div>
-            </div>
-
-            {/* Table Type Override */}
-            <div className="space-y-3">
-              <Label>Table Type Override</Label>
-              <div className="flex gap-3">
-                <Select
-                  value={settings.table_type_override || 'auto'}
-                  onValueChange={(value) =>
-                    setSettings({ ...settings, table_type_override: value === 'auto' ? undefined : value })
-                  }
-                >
-                  <SelectTrigger className="flex-1">
-                    <SelectValue placeholder="Auto-detect (use detected type)" />
-                  </SelectTrigger>
-                  <SelectContent>
-                    <SelectItem value="auto">Auto-detect (use detected type)</SelectItem>
-                    {settings.available_table_types?.map((type) => (
-                      <SelectItem key={type.value} value={type.value}>
-                        {type.label}
-                      </SelectItem>
-                    ))}
-                  </SelectContent>
-                </Select>
-                <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 automatically detected table type. This affects gear ratio calculations and homing behavior.
-              </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>
-                Table type is normally detected automatically from GRBL settings. Use override if auto-detection is incorrect for your hardware.
-              </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>
-
-        {/* Homing Configuration */}
-        <AccordionItem value="homing" id="section-homing" className="border rounded-lg px-4 overflow-visible bg-card">
-          <AccordionTrigger className="hover:no-underline">
-            <div className="flex items-center gap-3">
-              <span className="material-icons-outlined text-muted-foreground">
-                home
-              </span>
-              <div className="text-left">
-                <div className="font-semibold">Homing Configuration</div>
-                <div className="text-sm text-muted-foreground font-normal">
-                  Homing mode and auto-home settings
-                </div>
-              </div>
-            </div>
-          </AccordionTrigger>
-          <AccordionContent className="pt-4 pb-6 space-y-6">
-            {/* Homing Mode Selection */}
-            <div className="space-y-3">
-              <Label>Homing Mode</Label>
-              <RadioGroup
-                value={String(settings.homing_mode || 0)}
-                onValueChange={(value) =>
-                  setSettings({ ...settings, homing_mode: parseInt(value) })
-                }
-                className="space-y-3"
-              >
-                <div className="flex items-start gap-3 p-3 border rounded-lg cursor-pointer hover:bg-muted/50">
-                  <RadioGroupItem value="0" id="homing-crash" className="mt-0.5" />
-                  <div className="flex-1">
-                    <Label htmlFor="homing-crash" className="font-medium cursor-pointer">
-                      Crash Homing
-                    </Label>
-                    <p className="text-xs text-muted-foreground mt-1">
-                      Y axis moves until physical stop, then theta and rho set to 0
-                    </p>
-                  </div>
-                </div>
-                <div className="flex items-start gap-3 p-3 border rounded-lg cursor-pointer hover:bg-muted/50">
-                  <RadioGroupItem value="1" id="homing-sensor" className="mt-0.5" />
-                  <div className="flex-1">
-                    <Label htmlFor="homing-sensor" className="font-medium cursor-pointer">
-                      Sensor Homing
-                    </Label>
-                    <p className="text-xs text-muted-foreground mt-1">
-                      Homes both X and Y axes using sensors
-                    </p>
-                  </div>
-                </div>
-              </RadioGroup>
-            </div>
-
-            {/* Sensor Offset (only visible for sensor mode) */}
-            {settings.homing_mode === 1 && (
-              <div className="space-y-3">
-                <Label htmlFor="angular-offset">Sensor Offset (degrees)</Label>
-                <Input
-                  id="angular-offset"
-                  type="number"
-                  min="0"
-                  max="360"
-                  step="0.1"
-                  value={settings.angular_offset ?? ''}
-                  onChange={(e) =>
-                    setSettings({
-                      ...settings,
-                      angular_offset: e.target.value === '' ? undefined : parseFloat(e.target.value),
-                    })
-                  }
-                  placeholder="0.0"
-                />
-                <p className="text-xs text-muted-foreground">
-                  Set the angle (in degrees) where your radial arm should be offset. Choose a value so the radial arm points East.
-                </p>
-              </div>
-            )}
-
-            {/* Auto-Home During Playlists */}
-            <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">autorenew</span>
-                    Auto-Home During Playlists
-                  </p>
-                  <p className="text-xs text-muted-foreground mt-1">
-                    Perform homing after a set number of patterns to maintain accuracy
-                  </p>
-                </div>
-                <Switch
-                  checked={settings.auto_home_enabled || false}
-                  onCheckedChange={(checked) =>
-                    setSettings({ ...settings, auto_home_enabled: checked })
-                  }
-                />
-              </div>
-
-              {settings.auto_home_enabled && (
-                <div className="space-y-3">
-                  <Label htmlFor="auto-home-patterns">Home after every X patterns</Label>
-                  <Input
-                    id="auto-home-patterns"
-                    type="number"
-                    min="1"
-                    max="100"
-                    value={settings.auto_home_after_patterns || 5}
-                    onChange={(e) =>
-                      setSettings({
-                        ...settings,
-                        auto_home_after_patterns: parseInt(e.target.value) || 5,
-                      })
-                    }
-                  />
-                  <p className="text-xs text-muted-foreground">
-                    Homing occurs after each main pattern completes (clear patterns don't count).
-                  </p>
-                </div>
-              )}
-            </div>
-
-            {/* Machine Reset on Theta Normalization */}
-            <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">restart_alt</span>
-                    Reset Machine on Theta Normalization
-                  </p>
-                  <p className="text-xs text-muted-foreground mt-1">
-                    Also reset the machine controller when normalizing theta
-                  </p>
-                </div>
-                <Switch
-                  checked={settings.hard_reset_theta || false}
-                  onCheckedChange={(checked) =>
-                    setSettings({ ...settings, hard_reset_theta: checked })
-                  }
-                />
-              </div>
-              <p className="text-xs text-muted-foreground">
-                When disabled (default), theta normalization only adjusts the angle mathematically.
-                When enabled, also resets the machine controller to clear position counters.
-              </p>
-            </div>
-
-            <Button
-              onClick={handleSaveHomingConfig}
-              disabled={isLoading === 'homing'}
-              className="gap-2"
-            >
-              {isLoading === 'homing' ? (
-                <span className="material-icons-outlined animate-spin">sync</span>
-              ) : (
-                <span className="material-icons-outlined">save</span>
-              )}
-              Save Homing Configuration
-            </Button>
-          </AccordionContent>
-        </AccordionItem>
-
-        {/* Application Settings */}
-        <AccordionItem value="application" id="section-application" className="border rounded-lg px-4 overflow-visible 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">Application Settings</div>
-                <div className="text-sm text-muted-foreground font-normal">
-                  Customize app name and branding
-                </div>
-              </div>
-            </div>
-          </AccordionTrigger>
-          <AccordionContent className="pt-4 pb-6 space-y-6">
-            {/* Custom Logo */}
-            <div className="space-y-3">
-              <Label>Custom Logo</Label>
-              <div className="flex flex-col sm:flex-row sm:items-center gap-4 p-4 rounded-lg border">
-                <div className="flex items-center gap-4">
-                  <div className="w-16 h-16 rounded-full overflow-hidden border bg-background flex items-center justify-center shrink-0">
-                    {settings.custom_logo ? (
-                      <img
-                        src={apiClient.getAssetUrl(`/static/custom/${settings.custom_logo}`)}
-                        alt="Custom Logo"
-                        className="w-full h-full object-cover"
-                      />
-                    ) : (
-                      <img
-                        src={apiClient.getAssetUrl('/static/android-chrome-192x192.png')}
-                        alt="Default Logo"
-                        className="w-full h-full object-cover"
-                      />
-                    )}
-                  </div>
-                  <div className="flex-1">
-                    <p className="font-medium">
-                      {settings.custom_logo ? 'Custom logo active' : 'Using default logo'}
-                    </p>
-                    <p className="text-sm text-muted-foreground">
-                      PNG, JPG, GIF, WebP or SVG (max 5MB)
-                    </p>
-                  </div>
-                </div>
-                <div className="flex gap-2 sm:ml-auto">
-                  <Button
-                    variant="secondary"
-                    size="sm"
-                    className="gap-2"
-                    disabled={isLoading === 'logo'}
-                    onClick={() => document.getElementById('logo-upload')?.click()}
-                  >
-                    {isLoading === 'logo' ? (
-                      <span className="material-icons-outlined animate-spin text-base">sync</span>
-                    ) : (
-                      <span className="material-icons-outlined text-base">upload</span>
-                    )}
-                    Upload
-                  </Button>
-                  {settings.custom_logo && (
-                    <Button
-                      variant="secondary"
-                      size="sm"
-                      className="gap-2 text-destructive hover:text-destructive"
-                      disabled={isLoading === 'logo'}
-                      onClick={handleDeleteLogo}
-                    >
-                      <span className="material-icons-outlined text-base">delete</span>
-                    </Button>
-                  )}
-                </div>
-                <input
-                  id="logo-upload"
-                  type="file"
-                  accept=".png,.jpg,.jpeg,.gif,.webp,.svg"
-                  className="hidden"
-                  onChange={handleLogoUpload}
-                />
-              </div>
-              <p className="text-xs text-muted-foreground">
-                A favicon will be automatically generated from your logo.
-              </p>
-            </div>
-
-            <Separator />
-
-            {/* App Name */}
-            <div className="space-y-3">
-              <Label htmlFor="appName">Application Name</Label>
-              <div className="flex gap-3">
-                <div className="relative flex-1">
-                  <Input
-                    id="appName"
-                    value={settings.app_name || ''}
-                    onChange={(e) =>
-                      setSettings({ ...settings, app_name: e.target.value })
-                    }
-                    placeholder="e.g., Dune Weaver"
-                  />
-                  <Button
-                    variant="ghost"
-                    size="sm"
-                    className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 p-0"
-                    onClick={() => setSettings({ ...settings, app_name: 'Dune Weaver' })}
-                  >
-                    <span className="material-icons text-base">restart_alt</span>
-                  </Button>
-                </div>
-                <Button
-                  onClick={handleSaveAppName}
-                  disabled={isLoading === 'appName'}
-                  className="gap-2"
-                >
-                  {isLoading === 'appName' ? (
-                    <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">
-                This name appears in the browser tab and header.
-              </p>
-            </div>
-          </AccordionContent>
-        </AccordionItem>
-
-        {/* Pattern Clearing */}
-        <AccordionItem value="clearing" id="section-clearing" className="border rounded-lg px-4 overflow-visible bg-card">
-          <AccordionTrigger className="hover:no-underline">
-            <div className="flex items-center gap-3">
-              <span className="material-icons-outlined text-muted-foreground">
-                cleaning_services
-              </span>
-              <div className="text-left">
-                <div className="font-semibold">Pattern Clearing</div>
-                <div className="text-sm text-muted-foreground font-normal">
-                  Customize clearing speed and patterns
-                </div>
-              </div>
-            </div>
-          </AccordionTrigger>
-          <AccordionContent className="pt-4 pb-6 space-y-6">
-            <p className="text-sm text-muted-foreground">
-              Customize the clearing behavior used when transitioning between patterns.
-            </p>
-
-            {/* Clearing Speed */}
-            <div className="p-4 rounded-lg border space-y-3">
-              <h4 className="font-medium">Clearing Speed</h4>
-              <p className="text-sm text-muted-foreground">
-                Set a custom speed for clearing patterns. Leave empty to use the default pattern speed.
-              </p>
-              <div className="space-y-3">
-                <Label htmlFor="clear-speed">Speed (steps per minute)</Label>
-                <Input
-                  id="clear-speed"
-                  type="number"
-                  min="50"
-                  max="2000"
-                  step="50"
-                  value={settings.clear_pattern_speed || ''}
-                  onChange={(e) =>
-                    setSettings({
-                      ...settings,
-                      clear_pattern_speed: e.target.value ? parseInt(e.target.value) : undefined,
-                    })
-                  }
-                  placeholder="Default (use pattern speed)"
-                />
-              </div>
-            </div>
-
-            {/* Custom Clear Patterns */}
-            <div className="p-4 rounded-lg border space-y-3">
-              <h4 className="font-medium">Custom Clear Patterns</h4>
-              <p className="text-sm text-muted-foreground">
-                Choose specific patterns to use when clearing. Leave empty for default behavior.
-              </p>
-
-              <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
-                <div className="space-y-3">
-                  <Label htmlFor="clear-from-in">Clear From Center Pattern</Label>
-                  <SearchableSelect
-                    value={settings.custom_clear_from_in || '__default__'}
-                    onValueChange={(value) =>
-                      setSettings({ ...settings, custom_clear_from_in: value === '__default__' ? undefined : value })
-                    }
-                    options={[
-                      { value: '__default__', label: 'Default (built-in)' },
-                      ...patternFiles.map((file) => ({ value: file, label: file })),
-                    ]}
-                    placeholder="Default (built-in)"
-                    searchPlaceholder="Search patterns..."
-                    emptyMessage="No patterns found"
-                  />
-                  <p className="text-xs text-muted-foreground">
-                    Pattern used when clearing from center outward.
-                  </p>
-                </div>
-
-                <div className="space-y-3">
-                  <Label htmlFor="clear-from-out">Clear From Perimeter Pattern</Label>
-                  <SearchableSelect
-                    value={settings.custom_clear_from_out || '__default__'}
-                    onValueChange={(value) =>
-                      setSettings({ ...settings, custom_clear_from_out: value === '__default__' ? undefined : value })
-                    }
-                    options={[
-                      { value: '__default__', label: 'Default (built-in)' },
-                      ...patternFiles.map((file) => ({ value: file, label: file })),
-                    ]}
-                    placeholder="Default (built-in)"
-                    searchPlaceholder="Search patterns..."
-                    emptyMessage="No patterns found"
-                  />
-                  <p className="text-xs text-muted-foreground">
-                    Pattern used when clearing from perimeter inward.
-                  </p>
-                </div>
-              </div>
-            </div>
-
-            <Button
-              onClick={handleSaveClearingSettings}
-              disabled={isLoading === 'clearing'}
-              className="gap-2"
-            >
-              {isLoading === 'clearing' ? (
-                <span className="material-icons-outlined animate-spin">sync</span>
-              ) : (
-                <span className="material-icons-outlined">save</span>
-              )}
-              Save Clearing Settings
-            </Button>
-          </AccordionContent>
-        </AccordionItem>
-
-        {/* LED Controller Configuration */}
-        <AccordionItem value="led" id="section-led" className="border rounded-lg px-4 overflow-visible bg-card">
-          <AccordionTrigger className="hover:no-underline">
-            <div className="flex items-center gap-3">
-              <span className="material-icons-outlined text-muted-foreground">
-                lightbulb
-              </span>
-              <div className="text-left">
-                <div className="font-semibold">LED Controller</div>
-                <div className="text-sm text-muted-foreground font-normal">
-                  WLED or local GPIO LED control
-                </div>
-              </div>
-            </div>
-          </AccordionTrigger>
-          <AccordionContent className="pt-4 pb-6 space-y-6">
-            {/* LED Provider Selection */}
-            <div className="space-y-3">
-              <Label>LED Provider</Label>
-              <RadioGroup
-                value={ledConfig.provider}
-                onValueChange={(value) =>
-                  setLedConfig({ ...ledConfig, provider: value as LedConfig['provider'] })
-                }
-                className="flex gap-4"
-              >
-                <div className="flex items-center space-x-2">
-                  <RadioGroupItem value="none" id="led-none" />
-                  <Label htmlFor="led-none" className="font-normal">None</Label>
-                </div>
-                <div className="flex items-center space-x-2">
-                  <RadioGroupItem value="wled" id="led-wled" />
-                  <Label htmlFor="led-wled" className="font-normal">WLED</Label>
-                </div>
-                <div className="flex items-center space-x-2">
-                  <RadioGroupItem value="dw_leds" id="led-dw" />
-                  <Label htmlFor="led-dw" className="font-normal">DW LEDs (GPIO)</Label>
-                </div>
-              </RadioGroup>
-            </div>
-
-            {/* WLED Config */}
-            {ledConfig.provider === 'wled' && (
-              <div className="space-y-3 p-4 rounded-lg border">
-                <Label htmlFor="wledIp">WLED IP Address</Label>
-                <Input
-                  id="wledIp"
-                  value={ledConfig.wled_ip || ''}
-                  onChange={(e) =>
-                    setLedConfig({ ...ledConfig, wled_ip: e.target.value })
-                  }
-                  placeholder="e.g., 192.168.1.100"
-                />
-                <p className="text-xs text-muted-foreground">
-                  Enter the IP address of your WLED controller
-                </p>
-              </div>
-            )}
-
-            {/* DW LEDs Config */}
-            {ledConfig.provider === 'dw_leds' && (
-              <div className="space-y-3 p-4 rounded-lg border">
-                <Alert className="flex items-start">
-                  <span className="material-icons-outlined text-base mr-2 shrink-0">info</span>
-                  <AlertDescription>
-                    Supports WS2812, WS2812B, SK6812 and other WS281x LED strips
-                  </AlertDescription>
-                </Alert>
-
-                <div className="grid grid-cols-2 gap-4">
-                  <div className="space-y-3">
-                    <Label htmlFor="numLeds">Number of LEDs</Label>
-                    <Input
-                      id="numLeds"
-                      type="text"
-                      inputMode="numeric"
-                      value={numLedsInput}
-                      onChange={(e) => {
-                        const val = e.target.value.replace(/[^0-9]/g, '')
-                        setNumLedsInput(val)
-                      }}
-                      onBlur={() => {
-                        const num = Math.min(1000, Math.max(1, parseInt(numLedsInput) || 60))
-                        setLedConfig({ ...ledConfig, num_leds: num })
-                        setNumLedsInput(String(num))
-                      }}
-                      onKeyDown={(e) => {
-                        if (e.key === 'Enter') {
-                          const num = Math.min(1000, Math.max(1, parseInt(numLedsInput) || 60))
-                          setLedConfig({ ...ledConfig, num_leds: num })
-                          setNumLedsInput(String(num))
-                        }
-                      }}
-                    />
-                  </div>
-                  <div className="space-y-3">
-                    <Label htmlFor="gpioPin">GPIO Pin</Label>
-                    <Select
-                      value={String(ledConfig.gpio_pin || 18)}
-                      onValueChange={(value) =>
-                        setLedConfig({ ...ledConfig, gpio_pin: parseInt(value) })
-                      }
-                    >
-                      <SelectTrigger>
-                        <SelectValue />
-                      </SelectTrigger>
-                      <SelectContent>
-                        <SelectItem value="12">GPIO 12 (PWM0)</SelectItem>
-                        <SelectItem value="13">GPIO 13 (PWM1)</SelectItem>
-                        <SelectItem value="18">GPIO 18 (PWM0)</SelectItem>
-                        <SelectItem value="19">GPIO 19 (PWM1)</SelectItem>
-                      </SelectContent>
-                    </Select>
-                  </div>
-                </div>
-
-                <div className="space-y-3">
-                  <Label htmlFor="pixelOrder">Pixel Color Order</Label>
-                  <Select
-                    value={ledConfig.pixel_order || 'RGB'}
-                    onValueChange={(value) =>
-                      setLedConfig({ ...ledConfig, pixel_order: value })
-                    }
-                  >
-                    <SelectTrigger>
-                      <SelectValue />
-                    </SelectTrigger>
-                    <SelectContent>
-                      <SelectGroup>
-                        <SelectLabel>RGB Strips (3-channel)</SelectLabel>
-                        <SelectItem value="RGB">RGB - WS2815/WS2811</SelectItem>
-                        <SelectItem value="GRB">GRB - WS2812/WS2812B</SelectItem>
-                        <SelectItem value="BGR">BGR - Some WS2811 variants</SelectItem>
-                        <SelectItem value="RBG">RBG - Rare variant</SelectItem>
-                        <SelectItem value="GBR">GBR - Rare variant</SelectItem>
-                        <SelectItem value="BRG">BRG - Rare variant</SelectItem>
-                      </SelectGroup>
-                      <SelectGroup>
-                        <SelectLabel>RGBW Strips (4-channel)</SelectLabel>
-                        <SelectItem value="GRBW">GRBW - SK6812 RGBW</SelectItem>
-                        <SelectItem value="RGBW">RGBW - SK6812 variant</SelectItem>
-                      </SelectGroup>
-                    </SelectContent>
-                  </Select>
-                </div>
-              </div>
-            )}
-
-            <Button
-              onClick={handleSaveLedConfig}
-              disabled={isLoading === 'led'}
-              className="gap-2"
-            >
-              {isLoading === 'led' ? (
-                <span className="material-icons-outlined animate-spin">sync</span>
-              ) : (
-                <span className="material-icons-outlined">save</span>
-              )}
-              Save LED Configuration
-            </Button>
-          </AccordionContent>
-        </AccordionItem>
-
-        {/* Home Assistant Integration */}
-        <AccordionItem value="mqtt" id="section-mqtt" className="border rounded-lg px-4 overflow-visible bg-card">
-          <AccordionTrigger className="hover:no-underline">
-            <div className="flex items-center gap-3">
-              <span className="material-icons-outlined text-muted-foreground">
-                home
-              </span>
-              <div className="text-left">
-                <div className="font-semibold">Home Assistant Integration</div>
-                <div className="text-sm text-muted-foreground font-normal">
-                  MQTT configuration for smart home control
-                </div>
-              </div>
-            </div>
-          </AccordionTrigger>
-          <AccordionContent className="pt-4 pb-6 space-y-6">
-            {/* Enable Toggle */}
-            <div className="flex items-center justify-between p-4 rounded-lg border">
-              <div>
-                <p className="font-medium">Enable MQTT</p>
-                <p className="text-sm text-muted-foreground">
-                  Connect to Home Assistant via MQTT
-                </p>
-              </div>
-              <Switch
-                checked={mqttConfig.enabled}
-                onCheckedChange={(checked) =>
-                  setMqttConfig({ ...mqttConfig, enabled: checked })
-                }
-              />
-            </div>
-
-            {mqttConfig.enabled && (
-              <div className="space-y-3">
-                {/* Broker Settings */}
-                <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
-                  <div className="space-y-3">
-                    <Label htmlFor="mqttBroker">
-                      Broker Address <span className="text-destructive">*</span>
-                    </Label>
-                    <Input
-                      id="mqttBroker"
-                      value={mqttConfig.broker || ''}
-                      onChange={(e) =>
-                        setMqttConfig({ ...mqttConfig, broker: e.target.value })
-                      }
-                      placeholder="e.g., 192.168.1.100"
-                    />
-                  </div>
-                  <div className="space-y-3">
-                    <Label htmlFor="mqttPort">Port</Label>
-                    <Input
-                      id="mqttPort"
-                      type="number"
-                      value={mqttConfig.port || 1883}
-                      onChange={(e) =>
-                        setMqttConfig({ ...mqttConfig, port: parseInt(e.target.value) })
-                      }
-                      placeholder="1883"
-                    />
-                  </div>
-                </div>
-
-                {/* Authentication */}
-                <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
-                  <div className="space-y-3">
-                    <Label htmlFor="mqttUser">Username</Label>
-                    <Input
-                      id="mqttUser"
-                      value={mqttConfig.username || ''}
-                      onChange={(e) =>
-                        setMqttConfig({ ...mqttConfig, username: e.target.value })
-                      }
-                      placeholder="Optional"
-                    />
-                  </div>
-                  <div className="space-y-3">
-                    <Label htmlFor="mqttPass">Password</Label>
-                    <Input
-                      id="mqttPass"
-                      type="password"
-                      value={mqttConfig.password || ''}
-                      onChange={(e) =>
-                        setMqttConfig({ ...mqttConfig, password: e.target.value })
-                      }
-                      placeholder="Optional"
-                    />
-                  </div>
-                </div>
-
-                <Separator />
-
-                {/* Device Settings */}
-                <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
-                  <div className="space-y-3">
-                    <Label htmlFor="mqttDeviceName">Device Name</Label>
-                    <Input
-                      id="mqttDeviceName"
-                      value={mqttConfig.device_name || 'Dune Weaver'}
-                      onChange={(e) =>
-                        setMqttConfig({ ...mqttConfig, device_name: e.target.value })
-                      }
-                    />
-                  </div>
-                  <div className="space-y-3">
-                    <Label htmlFor="mqttDeviceId">Device ID</Label>
-                    <Input
-                      id="mqttDeviceId"
-                      value={mqttConfig.device_id || 'dune_weaver'}
-                      onChange={(e) =>
-                        setMqttConfig({ ...mqttConfig, device_id: e.target.value })
-                      }
-                    />
-                  </div>
-                </div>
-
-                <Alert className="flex items-start">
-                  <span className="material-icons-outlined text-base mr-2 shrink-0">info</span>
-                  <AlertDescription>
-                    MQTT configuration changes require a restart to take effect.
-                  </AlertDescription>
-                </Alert>
-              </div>
-            )}
-
-            <div className="flex flex-wrap gap-3">
-              <Button
-                onClick={handleSaveMqttConfig}
-                disabled={isLoading === 'mqtt'}
-                className="gap-2"
-              >
-                {isLoading === 'mqtt' ? (
-                  <span className="material-icons-outlined animate-spin">sync</span>
-                ) : (
-                  <span className="material-icons-outlined">save</span>
-                )}
-                Save MQTT Configuration
-              </Button>
-              {mqttConfig.enabled && mqttConfig.broker && (
-                <Button
-                  variant="secondary"
-                  onClick={handleTestMqttConnection}
-                  disabled={isLoading === 'mqttTest'}
-                  className="gap-2"
-                >
-                  {isLoading === 'mqttTest' ? (
-                    <span className="material-icons-outlined animate-spin">sync</span>
-                  ) : (
-                    <span className="material-icons-outlined">wifi_tethering</span>
-                  )}
-                  Test Connection
-                </Button>
-              )}
-            </div>
-          </AccordionContent>
-        </AccordionItem>
-
-        {/* Auto-play on Boot */}
-        <AccordionItem value="autoplay" id="section-autoplay" className="border rounded-lg px-4 overflow-visible bg-card">
-          <AccordionTrigger className="hover:no-underline">
-            <div className="flex items-center gap-3">
-              <span className="material-icons-outlined text-muted-foreground">
-                play_circle
-              </span>
-              <div className="text-left">
-                <div className="font-semibold">Auto-play on Boot</div>
-                <div className="text-sm text-muted-foreground font-normal">
-                  Start a playlist automatically on startup
-                </div>
-              </div>
-            </div>
-          </AccordionTrigger>
-          <AccordionContent className="pt-4 pb-6 space-y-6">
-            <div className="flex items-center justify-between p-4 rounded-lg border">
-              <div>
-                <p className="font-medium">Enable Auto-play</p>
-                <p className="text-sm text-muted-foreground">
-                  Automatically start playing when the system boots
-                </p>
-              </div>
-              <Switch
-                checked={autoPlaySettings.enabled}
-                onCheckedChange={(checked) =>
-                  setAutoPlaySettings({ ...autoPlaySettings, enabled: checked })
-                }
-              />
-            </div>
-
-            {autoPlaySettings.enabled && (
-              <div className="space-y-3 p-4 rounded-lg border">
-                <div className="space-y-3">
-                  <Label>Startup Playlist</Label>
-                  <Select
-                    value={autoPlaySettings.playlist || undefined}
-                    onValueChange={(value) =>
-                      setAutoPlaySettings({ ...autoPlaySettings, playlist: value })
-                    }
-                  >
-                    <SelectTrigger>
-                      <SelectValue placeholder="Select a playlist..." />
-                    </SelectTrigger>
-                    <SelectContent>
-                      {playlists.length === 0 ? (
-                        <div className="py-6 text-center text-sm text-muted-foreground">
-                          No playlists found
-                        </div>
-                      ) : (
-                        playlists.map((playlist) => (
-                          <SelectItem key={playlist} value={playlist}>
-                            {playlist}
-                          </SelectItem>
-                        ))
-                      )}
-                    </SelectContent>
-                  </Select>
-                  <p className="text-xs text-muted-foreground">
-                    Choose which playlist to play when the system starts.
-                  </p>
-                </div>
-
-                <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
-                  <div className="space-y-3">
-                    <Label>Run Mode</Label>
-                    <Select
-                      value={autoPlaySettings.run_mode}
-                      onValueChange={(value) =>
-                        setAutoPlaySettings({
-                          ...autoPlaySettings,
-                          run_mode: value as 'single' | 'loop',
-                        })
-                      }
-                    >
-                      <SelectTrigger>
-                        <SelectValue />
-                      </SelectTrigger>
-                      <SelectContent>
-                        <SelectItem value="single">Single (play once)</SelectItem>
-                        <SelectItem value="loop">Loop (repeat forever)</SelectItem>
-                      </SelectContent>
-                    </Select>
-                  </div>
-                  <div className="space-y-3">
-                    <Label>Pause Between Patterns</Label>
-                    <div className="flex gap-2">
-                      <Input
-                        type="text"
-                        inputMode="numeric"
-                        value={autoPlayPauseInput}
-                        onChange={(e) => {
-                          const val = e.target.value.replace(/[^0-9]/g, '')
-                          setAutoPlayPauseInput(val)
-                        }}
-                        onBlur={() => {
-                          const num = Math.max(0, parseInt(autoPlayPauseInput) || 0)
-                          setAutoPlayPauseValue(num)
-                          setAutoPlayPauseInput(String(num))
-                        }}
-                        onKeyDown={(e) => {
-                          if (e.key === 'Enter') {
-                            const num = Math.max(0, parseInt(autoPlayPauseInput) || 0)
-                            setAutoPlayPauseValue(num)
-                            setAutoPlayPauseInput(String(num))
-                          }
-                        }}
-                        className="w-20"
-                      />
-                      <Select
-                        value={autoPlayPauseUnit}
-                        onValueChange={(v) => setAutoPlayPauseUnit(v as 'sec' | 'min' | 'hr')}
-                      >
-                        <SelectTrigger className="w-20">
-                          <SelectValue />
-                        </SelectTrigger>
-                        <SelectContent>
-                          <SelectItem value="sec">sec</SelectItem>
-                          <SelectItem value="min">min</SelectItem>
-                          <SelectItem value="hr">hr</SelectItem>
-                        </SelectContent>
-                      </Select>
-                    </div>
-                  </div>
-                </div>
-
-                <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
-                  <div className="space-y-3">
-                    <Label>Clear Pattern</Label>
-                    <Select
-                      value={autoPlaySettings.clear_pattern}
-                      onValueChange={(value) =>
-                        setAutoPlaySettings({ ...autoPlaySettings, clear_pattern: value })
-                      }
-                    >
-                      <SelectTrigger>
-                        <SelectValue />
-                      </SelectTrigger>
-                      <SelectContent>
-                        <SelectItem value="none">None</SelectItem>
-                        <SelectItem value="adaptive">Adaptive</SelectItem>
-                        <SelectItem value="clear_from_in">Clear From Center</SelectItem>
-                        <SelectItem value="clear_from_out">Clear From Perimeter</SelectItem>
-                        <SelectItem value="clear_sideway">Clear Sideways</SelectItem>
-                        <SelectItem value="random">Random</SelectItem>
-                      </SelectContent>
-                    </Select>
-                    <p className="text-xs text-muted-foreground">
-                      Pattern to run before each main pattern.
-                    </p>
-                  </div>
-
-                  <div className="flex items-center justify-between">
-                    <div className="flex-1">
-                      <p className="text-sm font-medium">Shuffle Playlist</p>
-                      <p className="text-xs text-muted-foreground">
-                        Randomize pattern order
-                      </p>
-                    </div>
-                    <Switch
-                      checked={autoPlaySettings.shuffle}
-                      onCheckedChange={(checked) =>
-                        setAutoPlaySettings({ ...autoPlaySettings, shuffle: checked })
-                      }
-                    />
-                  </div>
-                </div>
-              </div>
-            )}
-
-            <Button
-              onClick={handleSaveAutoPlaySettings}
-              disabled={isLoading === 'autoplay'}
-              className="gap-2"
-            >
-              {isLoading === 'autoplay' ? (
-                <span className="material-icons-outlined animate-spin">sync</span>
-              ) : (
-                <span className="material-icons-outlined">save</span>
-              )}
-              Save Auto-play Settings
-            </Button>
-          </AccordionContent>
-        </AccordionItem>
-
-        {/* Still Sands */}
-        <AccordionItem value="stillsands" id="section-stillsands" className="border rounded-lg px-4 overflow-visible bg-card">
-          <AccordionTrigger className="hover:no-underline">
-            <div className="flex items-center gap-3">
-              <span className="material-icons-outlined text-muted-foreground">
-                bedtime
-              </span>
-              <div className="text-left">
-                <div className="font-semibold">Still Sands</div>
-                <div className="text-sm text-muted-foreground font-normal">
-                  Schedule quiet periods for your table
-                </div>
-              </div>
-            </div>
-          </AccordionTrigger>
-          <AccordionContent className="pt-4 pb-6 space-y-6">
-            <div className="flex items-center justify-between p-4 rounded-lg border">
-              <div>
-                <p className="font-medium">Enable Still Sands</p>
-                <p className="text-sm text-muted-foreground">
-                  Pause the table during specified time periods
-                </p>
-              </div>
-              <Switch
-                checked={stillSandsSettings.enabled}
-                onCheckedChange={(checked) =>
-                  setStillSandsSettings({ ...stillSandsSettings, enabled: checked })
-                }
-              />
-            </div>
-
-            {stillSandsSettings.enabled && (
-              <div className="space-y-3">
-                {/* Options */}
-                <div className="p-4 rounded-lg border space-y-3">
-                  <div className="flex items-center justify-between">
-                    <div className="flex items-center gap-2">
-                      <span className="material-icons-outlined text-base text-muted-foreground">
-                        hourglass_bottom
-                      </span>
-                      <div>
-                        <p className="text-sm font-medium">Finish Current Pattern</p>
-                        <p className="text-xs text-muted-foreground">
-                          Let the current pattern complete before entering still mode
-                        </p>
-                      </div>
-                    </div>
-                    <Switch
-                      checked={stillSandsSettings.finish_pattern}
-                      onCheckedChange={(checked) =>
-                        setStillSandsSettings({ ...stillSandsSettings, finish_pattern: checked })
-                      }
-                    />
-                  </div>
-
-                  <Separator />
-
-                  <div className="flex items-center justify-between">
-                    <div className="flex items-center gap-2">
-                      <span className="material-icons-outlined text-base text-muted-foreground">
-                        lightbulb
-                      </span>
-                      <div>
-                        <p className="text-sm font-medium">Control LED Lights</p>
-                        <p className="text-xs text-muted-foreground">
-                          Turn off LED lights during still periods
-                        </p>
-                      </div>
-                    </div>
-                    <Switch
-                      checked={stillSandsSettings.control_wled}
-                      onCheckedChange={(checked) =>
-                        setStillSandsSettings({ ...stillSandsSettings, control_wled: checked })
-                      }
-                    />
-                  </div>
-
-                  {/* Timezone */}
-                  <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 pt-3 border-t">
-                    <div className="flex items-center gap-3">
-                      <span className="material-icons-outlined text-muted-foreground">
-                        schedule
-                      </span>
-                      <div>
-                        <p className="text-sm font-medium">Timezone</p>
-                        <p className="text-xs text-muted-foreground">
-                          Select a timezone for scheduling
-                        </p>
-                      </div>
-                    </div>
-                    <SearchableSelect
-                      value={stillSandsSettings.timezone || ''}
-                      onValueChange={(value) =>
-                        setStillSandsSettings({ ...stillSandsSettings, timezone: value })
-                      }
-                      placeholder="System Default"
-                      searchPlaceholder="Search timezones..."
-                      className="w-full sm:w-[200px]"
-                      options={[
-                        { value: '', label: 'System Default' },
-                        { value: 'Etc/GMT+12', label: 'UTC-12' },
-                        { value: 'Etc/GMT+11', label: 'UTC-11' },
-                        { value: 'Etc/GMT+10', label: 'UTC-10' },
-                        { value: 'Etc/GMT+9', label: 'UTC-9' },
-                        { value: 'Etc/GMT+8', label: 'UTC-8' },
-                        { value: 'Etc/GMT+7', label: 'UTC-7' },
-                        { value: 'Etc/GMT+6', label: 'UTC-6' },
-                        { value: 'Etc/GMT+5', label: 'UTC-5' },
-                        { value: 'Etc/GMT+4', label: 'UTC-4' },
-                        { value: 'Etc/GMT+3', label: 'UTC-3' },
-                        { value: 'Etc/GMT+2', label: 'UTC-2' },
-                        { value: 'Etc/GMT+1', label: 'UTC-1' },
-                        { value: 'UTC', label: 'UTC' },
-                        { value: 'Etc/GMT-1', label: 'UTC+1' },
-                        { value: 'Etc/GMT-2', label: 'UTC+2' },
-                        { value: 'Etc/GMT-3', label: 'UTC+3' },
-                        { value: 'Etc/GMT-4', label: 'UTC+4' },
-                        { value: 'Etc/GMT-5', label: 'UTC+5' },
-                        { value: 'Etc/GMT-6', label: 'UTC+6' },
-                        { value: 'Etc/GMT-7', label: 'UTC+7' },
-                        { value: 'Etc/GMT-8', label: 'UTC+8' },
-                        { value: 'Etc/GMT-9', label: 'UTC+9' },
-                        { value: 'Etc/GMT-10', label: 'UTC+10' },
-                        { value: 'Etc/GMT-11', label: 'UTC+11' },
-                        { value: 'Etc/GMT-12', label: 'UTC+12' },
-                        { value: 'America/New_York', label: 'America/New_York (Eastern)' },
-                        { value: 'America/Chicago', label: 'America/Chicago (Central)' },
-                        { value: 'America/Denver', label: 'America/Denver (Mountain)' },
-                        { value: 'America/Los_Angeles', label: 'America/Los_Angeles (Pacific)' },
-                        { value: 'Europe/London', label: 'Europe/London' },
-                        { value: 'Europe/Paris', label: 'Europe/Paris' },
-                        { value: 'Europe/Berlin', label: 'Europe/Berlin' },
-                        { value: 'Asia/Tokyo', label: 'Asia/Tokyo' },
-                        { value: 'Asia/Shanghai', label: 'Asia/Shanghai' },
-                        { value: 'Asia/Singapore', label: 'Asia/Singapore' },
-                        { value: 'Australia/Sydney', label: 'Australia/Sydney' },
-                      ]}
-                    />
-                  </div>
-                </div>
-
-                {/* Time Slots */}
-                <div className="p-4 rounded-lg border space-y-3">
-                  <div className="flex items-center justify-between">
-                    <h4 className="font-medium">Still Periods</h4>
-                    <Button onClick={addTimeSlot} size="sm" variant="secondary" className="gap-1">
-                      <span className="material-icons text-base">add</span>
-                      Add Period
-                    </Button>
-                  </div>
-
-                  <p className="text-sm text-muted-foreground">
-                    Define time periods when the sands should rest.
-                  </p>
-
-                  {stillSandsSettings.time_slots.length === 0 ? (
-                    <div className="text-center py-6 text-muted-foreground">
-                      <span className="material-icons text-3xl mb-2">schedule</span>
-                      <p className="text-sm">No still periods configured</p>
-                      <p className="text-xs">Click "Add Period" to create one</p>
-                    </div>
-                  ) : (
-                    <div className="space-y-3">
-                      {stillSandsSettings.time_slots.map((slot, index) => (
-                        <div
-                          key={index}
-                          className="p-3 border rounded-lg bg-muted/50 space-y-3 overflow-hidden"
-                        >
-                          <div className="flex items-center justify-between -mr-1">
-                            <span className="text-sm font-medium">Period {index + 1}</span>
-                            <Button
-                              variant="ghost"
-                              size="icon"
-                              onClick={() => removeTimeSlot(index)}
-                              className="h-7 w-7 text-destructive hover:text-destructive"
-                            >
-                              <span className="material-icons text-lg">delete</span>
-                            </Button>
-                          </div>
-
-                          <div className="grid grid-cols-[1fr_1fr] gap-2">
-                            <div className="space-y-1.5 min-w-0 overflow-hidden">
-                              <Label className="text-xs">Start Time</Label>
-                              <Input
-                                type="time"
-                                value={slot.start_time}
-                                onChange={(e) =>
-                                  updateTimeSlot(index, { start_time: e.target.value })
-                                }
-                                className="text-xs w-full"
-                              />
-                            </div>
-                            <div className="space-y-1.5 min-w-0 overflow-hidden">
-                              <Label className="text-xs">End Time</Label>
-                              <Input
-                                type="time"
-                                value={slot.end_time}
-                                onChange={(e) =>
-                                  updateTimeSlot(index, { end_time: e.target.value })
-                                }
-                                className="text-xs w-full"
-                              />
-                            </div>
-                          </div>
-
-                          <div className="space-y-1.5">
-                            <Label className="text-xs">Days</Label>
-                            <Select
-                              value={slot.days}
-                              onValueChange={(value) =>
-                                updateTimeSlot(index, {
-                                  days: value as TimeSlot['days'],
-                                  ...(value !== 'custom' ? { custom_days: [] } : {}),
-                                })
-                              }
-                            >
-                              <SelectTrigger>
-                                <SelectValue />
-                              </SelectTrigger>
-                              <SelectContent>
-                                <SelectItem value="daily">Daily</SelectItem>
-                                <SelectItem value="weekdays">Weekdays</SelectItem>
-                                <SelectItem value="weekends">Weekends</SelectItem>
-                                <SelectItem value="custom">Custom</SelectItem>
-                              </SelectContent>
-                            </Select>
-                          </div>
-
-                          {slot.days === 'custom' && (
-                            <div className="space-y-1.5">
-                              <Label className="text-xs">Select Days</Label>
-                              <div className="flex flex-wrap gap-1.5">
-                                {[
-                                  { key: 'monday', label: 'Mon' },
-                                  { key: 'tuesday', label: 'Tue' },
-                                  { key: 'wednesday', label: 'Wed' },
-                                  { key: 'thursday', label: 'Thu' },
-                                  { key: 'friday', label: 'Fri' },
-                                  { key: 'saturday', label: 'Sat' },
-                                  { key: 'sunday', label: 'Sun' },
-                                ].map((day) => {
-                                  const isSelected = slot.custom_days?.includes(day.key)
-                                  return (
-                                    <button
-                                      key={day.key}
-                                      type="button"
-                                      onClick={() => {
-                                        const currentDays = slot.custom_days || []
-                                        const newDays = isSelected
-                                          ? currentDays.filter((d) => d !== day.key)
-                                          : [...currentDays, day.key]
-                                        updateTimeSlot(index, { custom_days: newDays })
-                                      }}
-                                      className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${
-                                        isSelected
-                                          ? 'bg-primary text-primary-foreground border-primary'
-                                          : 'bg-background text-muted-foreground border-input hover:bg-accent'
-                                      }`}
-                                    >
-                                      {day.label}
-                                    </button>
-                                  )
-                                })}
-                              </div>
-                            </div>
-                          )}
-                        </div>
-                      ))}
-                    </div>
-                  )}
-                </div>
-
-                <Alert className="flex items-start">
-                  <span className="material-icons-outlined text-base mr-2 shrink-0">info</span>
-                  <AlertDescription>
-                    Times are based on the timezone selected above (or system default). Still
-                    periods that span midnight (e.g., 22:00 to 06:00) are supported. Patterns
-                    resume automatically when still periods end.
-                  </AlertDescription>
-                </Alert>
-              </div>
-            )}
-
-            <Button
-              onClick={handleSaveStillSandsSettings}
-              disabled={isLoading === 'stillsands'}
-              className="gap-2"
-            >
-              {isLoading === 'stillsands' ? (
-                <span className="material-icons-outlined animate-spin">sync</span>
-              ) : (
-                <span className="material-icons-outlined">save</span>
-              )}
-              Save Still Sands Settings
-            </Button>
-          </AccordionContent>
-        </AccordionItem>
-
-        {/* Security */}
-        <AccordionItem value="security" id="section-security" className="border rounded-lg px-4 overflow-visible bg-card">
-          <AccordionTrigger className="hover:no-underline">
-            <div className="flex items-center gap-3">
-              <span className="material-icons-outlined text-muted-foreground">
-                lock
-              </span>
-              <div className="text-left">
-                <div className="font-semibold">Security</div>
-                <div className="text-sm text-muted-foreground font-normal">
-                  App lock and access control
-                </div>
-              </div>
-            </div>
-          </AccordionTrigger>
-          <AccordionContent className="pt-4 pb-6 space-y-4">
-            <p className="text-sm text-muted-foreground">
-              Restrict access to the app to prevent unauthorized changes. Useful for shared spaces or when the table is accessible to children.
-            </p>
-
-            {/* Security Mode */}
-            <div className="space-y-3">
-              <Label className="text-sm font-medium">Security Mode</Label>
-              <RadioGroup
-                value={securityMode}
-                onValueChange={(value) => {
-                  const newMode = value as 'off' | 'lockdown' | 'play_only'
-                  if (newMode === 'off' && securityMode !== 'off') {
-                    if (!confirm('Turn off security? This will remove the password and unlock the app.')) return
-                  }
-                  setSecurityMode(newMode)
-                  // Clear password fields when switching modes
-                  setSecurityPassword('')
-                  setSecurityPasswordConfirm('')
-                }}
-                className="space-y-2"
-              >
-                <div className="flex items-start gap-3 p-3 rounded-lg border">
-                  <RadioGroupItem value="off" id="security-off" className="mt-0.5" />
-                  <div>
-                    <Label htmlFor="security-off" className="font-medium cursor-pointer">Off</Label>
-                    <p className="text-sm text-muted-foreground">No restrictions. Anyone can use the app.</p>
-                  </div>
-                </div>
-                <div className="flex items-start gap-3 p-3 rounded-lg border">
-                  <RadioGroupItem value="play_only" id="security-play-only" className="mt-0.5" />
-                  <div>
-                    <Label htmlFor="security-play-only" className="font-medium cursor-pointer">Play Only</Label>
-                    <p className="text-sm text-muted-foreground">Anyone can browse and play patterns. Settings require a password.</p>
-                  </div>
-                </div>
-                <div className="flex items-start gap-3 p-3 rounded-lg border">
-                  <RadioGroupItem value="lockdown" id="security-lockdown" className="mt-0.5" />
-                  <div>
-                    <Label htmlFor="security-lockdown" className="font-medium cursor-pointer">Full Lockdown</Label>
-                    <p className="text-sm text-muted-foreground">Password required to access the entire app.</p>
-                  </div>
-                </div>
-              </RadioGroup>
-            </div>
-
-            {/* Password fields (shown when mode != off) */}
-            {securityMode !== 'off' && (
-              <div className="space-y-3 pt-2">
-                <Separator />
-                {hasExistingPassword && (
-                  <p className="text-sm text-muted-foreground">
-                    A password is currently set. Enter a new password below to change it, or leave blank to keep the existing one.
-                  </p>
-                )}
-                <div className="space-y-2">
-                  <Label htmlFor="security-password">
-                    {hasExistingPassword ? 'New Password' : 'Password'}
-                  </Label>
-                  <Input
-                    id="security-password"
-                    type="password"
-                    placeholder={hasExistingPassword ? 'Leave blank to keep current' : 'Enter password'}
-                    value={securityPassword}
-                    onChange={(e) => setSecurityPassword(e.target.value)}
-                  />
-                </div>
-                <div className="space-y-2">
-                  <Label htmlFor="security-password-confirm">Confirm Password</Label>
-                  <Input
-                    id="security-password-confirm"
-                    type="password"
-                    placeholder="Confirm password"
-                    value={securityPasswordConfirm}
-                    onChange={(e) => setSecurityPasswordConfirm(e.target.value)}
-                  />
-                </div>
-                {securityPassword && securityPasswordConfirm && securityPassword !== securityPasswordConfirm && (
-                  <p className="text-sm text-destructive">Passwords do not match</p>
-                )}
-              </div>
-            )}
-
-            {/* Save button */}
-            <Button
-              onClick={async () => {
-                // Validate
-                if (securityMode !== 'off') {
-                  if (securityPassword && securityPassword !== securityPasswordConfirm) {
-                    toast.error('Passwords do not match')
-                    return
-                  }
-                  if (!hasExistingPassword && !securityPassword) {
-                    toast.error('Please set a password')
-                    return
-                  }
-                }
-
-                setIsLoading('security')
-                try {
-                  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-                  const payload: any = { security: { mode: securityMode } }
-                  if (securityPassword) {
-                    payload.security.password = securityPassword
-                  }
-                  await apiClient.patch('/api/settings', payload)
-                  toast.success('Security settings saved')
-                  setSecurityPassword('')
-                  setSecurityPasswordConfirm('')
-                  setHasExistingPassword(securityMode !== 'off')
-                  // Notify Layout to refetch security state
-                  window.dispatchEvent(new CustomEvent('security-updated'))
-                } catch {
-                  toast.error('Failed to save security settings')
-                } finally {
-                  setIsLoading(null)
-                }
-              }}
-              disabled={
-                isLoading === 'security' ||
-                (securityMode !== 'off' && securityPassword !== '' && securityPassword !== securityPasswordConfirm) ||
-                (securityMode !== 'off' && !hasExistingPassword && !securityPassword)
-              }
-              className="w-full gap-2"
-            >
-              {isLoading === 'security' ? (
-                <span className="material-icons-outlined animate-spin">sync</span>
-              ) : (
-                <span className="material-icons-outlined">save</span>
-              )}
-              Save Security Settings
-            </Button>
-          </AccordionContent>
-        </AccordionItem>
-
-        {/* WiFi */}
-        <AccordionItem value="wifi" id="section-wifi" className="border rounded-lg px-4 overflow-visible bg-card">
-          <AccordionTrigger className="hover:no-underline">
-            <div className="flex items-center gap-3">
-              <span className="material-icons-outlined text-muted-foreground">
-                wifi
-              </span>
-              <div className="text-left">
-                <div className="font-semibold">WiFi</div>
-                <div className="text-sm text-muted-foreground font-normal">
-                  Network connection settings
-                </div>
-              </div>
-            </div>
-          </AccordionTrigger>
-          <AccordionContent className="pt-4 pb-6 space-y-3">
-            <p className="text-sm text-muted-foreground">
-              Manage WiFi connections, scan for networks, and configure hotspot mode.
-            </p>
-            <Button
-              variant="outline"
-              className="w-full gap-2"
-              onClick={() => navigate('/wifi-setup')}
-            >
-              <span className="material-icons-outlined">settings</span>
-              Open WiFi Setup
-            </Button>
-          </AccordionContent>
-        </AccordionItem>
-
-        {/* Software Version */}
-        <AccordionItem value="version" id="section-version" className="border rounded-lg px-4 overflow-visible bg-card">
-          <AccordionTrigger className="hover:no-underline">
-            <div className="flex items-center gap-3">
-              <span className="material-icons-outlined text-muted-foreground">
-                info
-              </span>
-              <div className="text-left">
-                <div className="font-semibold">Software Version</div>
-                <div className="text-sm text-muted-foreground font-normal">
-                  Updates and system information
-                </div>
-              </div>
-            </div>
-          </AccordionTrigger>
-          <AccordionContent className="pt-4 pb-6 space-y-3">
-            <div className="flex items-center gap-4 p-4 rounded-lg bg-muted/50">
-              <div className="w-10 h-10 flex items-center justify-center bg-background rounded-lg">
-                <span className="material-icons text-muted-foreground">terminal</span>
-              </div>
-              <div className="flex-1">
-                <p className="font-medium">Current Version</p>
-                <p className="text-sm text-muted-foreground">
-                  {versionInfo?.current ? `v${versionInfo.current}` : 'Loading...'}
-                </p>
-              </div>
-            </div>
-
-            <div className="flex items-center gap-4 p-4 rounded-lg bg-muted/50">
-              <div className="w-10 h-10 flex items-center justify-center bg-background rounded-lg">
-                <span className="material-icons text-muted-foreground">system_update</span>
-              </div>
-              <div className="flex-1">
-                <p className="font-medium">Latest Version</p>
-                <p className={`text-sm ${versionInfo?.update_available ? 'text-green-600 dark:text-green-400 font-medium' : 'text-muted-foreground'}`}>
-                  {versionInfo?.latest ? (
-                    <a
-                      href={`https://github.com/tuanchris/dune-weaver/releases/tag/v${versionInfo.latest}`}
-                      target="_blank"
-                      rel="noopener noreferrer"
-                      className="underline underline-offset-2 hover:opacity-80 transition-opacity"
-                    >
-                      v{versionInfo.latest}
-                    </a>
-                  ) : 'Checking...'}
-                  {versionInfo?.update_available && ' (Update available!)'}
-                </p>
-              </div>
-            </div>
-
-            {versionInfo?.update_available && (
-              <Button onClick={() => setUpdateDialogOpen(true)} className="w-full">
-                <span className="material-icons text-base mr-2">system_update</span>
-                Update Now
-              </Button>
-            )}
-
-            <UpdateDialog
-              open={updateDialogOpen}
-              onOpenChange={setUpdateDialogOpen}
-              currentVersion={versionInfo?.current || ''}
-              latestVersion={versionInfo?.latest || ''}
-            />
-          </AccordionContent>
-        </AccordionItem>
-      </Accordion>
-    </div>
-  )
-}
+import { useState, useEffect } from 'react'
+import { useSearchParams, useNavigate } from 'react-router-dom'
+import { toast } from 'sonner'
+import { apiClient } from '@/lib/apiClient'
+import { useOnBackendConnected } from '@/hooks/useBackendConnection'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Separator } from '@/components/ui/separator'
+import { Switch } from '@/components/ui/switch'
+import { Alert, AlertDescription } from '@/components/ui/alert'
+import {
+  Accordion,
+  AccordionContent,
+  AccordionItem,
+  AccordionTrigger,
+} from '@/components/ui/accordion'
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
+import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
+import { SearchableSelect } from '@/components/ui/searchable-select'
+import { UpdateDialog } from '@/components/UpdateDialog'
+
+// Types
+
+interface Settings {
+  app_name?: string
+  custom_logo?: string
+  // Homing settings
+  homing_mode?: number
+  angular_offset?: number
+  home_on_connect?: boolean
+  auto_home_enabled?: boolean
+  auto_home_after_patterns?: number
+  hard_reset_theta?: boolean
+  // Pattern clearing settings
+  clear_pattern_speed?: number
+  custom_clear_from_in?: string
+  custom_clear_from_out?: string
+}
+
+interface TimeSlot {
+  start_time: string
+  end_time: string
+  days: 'daily' | 'weekdays' | 'weekends' | 'custom'
+  custom_days?: string[]
+}
+
+interface StillSandsSettings {
+  enabled: boolean
+  finish_pattern: boolean
+  control_wled: boolean
+  timezone: string
+  time_slots: TimeSlot[]
+}
+
+// Auto-play on boot lives on the board ($Playlist/Autostart*): it fires when
+// the table powers on and homes, independent of this backend. enabled is
+// derived from a non-empty playlist, like the mobile app.
+interface AutoPlaySettings {
+  enabled: boolean
+  playlist: string
+  run_mode: 'single' | 'loop'
+  pause_time: number
+  pause_from_start: boolean
+  clear_pattern: string
+  shuffle: boolean
+}
+
+interface BoardTime {
+  epoch: number
+  synced: boolean
+  local: string
+  tz: string
+}
+
+interface LedConfig {
+  provider: 'none' | 'wled' | 'board'
+  wled_ip?: string
+}
+
+interface MqttConfig {
+  enabled: boolean
+  broker?: string
+  port?: number
+  username?: string
+  password?: string
+  device_name?: string
+  device_id?: string
+  client_id?: string
+  discovery_prefix?: string
+}
+
+export function SettingsPage() {
+  const [searchParams, setSearchParams] = useSearchParams()
+  const navigate = useNavigate()
+  const sectionParam = searchParams.get('section')
+
+  // Connection state — the board is reached over HTTP (FluidNC firmware),
+  // identified by an IP or hostname instead of a serial port.
+  const [boardAddress, setBoardAddress] = useState('')
+  const [isConnected, setIsConnected] = useState(false)
+  const [connectionStatus, setConnectionStatus] = useState('Disconnected')
+
+  // Settings state
+  const [settings, setSettings] = useState<Settings>({})
+  const [ledConfig, setLedConfig] = useState<LedConfig>({ provider: 'none' })
+  const [mqttConfig, setMqttConfig] = useState<MqttConfig>({ enabled: false })
+
+  // UI state
+  const [isLoading, setIsLoading] = useState<string | null>(null)
+
+  // Accordion state - controlled by URL params
+  const [openSections, setOpenSections] = useState<string[]>(() => {
+    if (sectionParam) return [sectionParam]
+    return ['connection']
+  })
+
+  // Track which sections have been loaded (for lazy loading)
+  const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set())
+
+  // Auto-play state (read from / written to the board)
+  const [autoPlaySettings, setAutoPlaySettings] = useState<AutoPlaySettings>({
+    enabled: false,
+    playlist: '',
+    run_mode: 'loop',
+    pause_time: 0,
+    pause_from_start: false,
+    clear_pattern: 'none',
+    shuffle: false,
+  })
+  const [boardReachable, setBoardReachable] = useState(true)
+  const [boardTime, setBoardTime] = useState<BoardTime | null>(null)
+  const [autoPlayPauseUnit, setAutoPlayPauseUnit] = useState<'sec' | 'min' | 'hr'>('min')
+  const [autoPlayPauseValue, setAutoPlayPauseValue] = useState(5)
+  const [autoPlayPauseInput, setAutoPlayPauseInput] = useState('5')
+  const [playlists, setPlaylists] = useState<string[]>([])
+
+  // Convert pause time from seconds to value + unit for display
+  const secondsToDisplayPause = (seconds: number): { value: number; unit: 'sec' | 'min' | 'hr' } => {
+    if (seconds >= 3600 && seconds % 3600 === 0) {
+      return { value: seconds / 3600, unit: 'hr' }
+    } else if (seconds >= 60 && seconds % 60 === 0) {
+      return { value: seconds / 60, unit: 'min' }
+    }
+    return { value: seconds, unit: 'sec' }
+  }
+
+  // Convert display value + unit to seconds
+  const displayPauseToSeconds = (value: number, unit: 'sec' | 'min' | 'hr'): number => {
+    switch (unit) {
+      case 'hr': return value * 3600
+      case 'min': return value * 60
+      default: return value
+    }
+  }
+
+  // Still Sands state
+  const [stillSandsSettings, setStillSandsSettings] = useState<StillSandsSettings>({
+    enabled: false,
+    finish_pattern: false,
+    control_wled: false,
+    timezone: '',
+    time_slots: [],
+  })
+
+  // Pattern search state for clearing patterns
+  const [patternFiles, setPatternFiles] = useState<string[]>([])
+
+  // Security state
+  const [securityMode, setSecurityMode] = useState<'off' | 'lockdown' | 'play_only'>('off')
+  const [securityPassword, setSecurityPassword] = useState('')
+  const [securityPasswordConfirm, setSecurityPasswordConfirm] = useState('')
+  const [hasExistingPassword, setHasExistingPassword] = useState(false)
+
+  // Version state
+  const [versionInfo, setVersionInfo] = useState<{
+    current: string
+    latest: string
+    update_available: boolean
+  } | null>(null)
+  const [updateDialogOpen, setUpdateDialogOpen] = useState(false)
+
+  // Helper to scroll to element with header offset
+  const scrollToSection = (sectionId: string) => {
+    const element = document.getElementById(`section-${sectionId}`)
+    if (element) {
+      const headerHeight = 80 // Header height + some padding
+      const elementTop = element.getBoundingClientRect().top + window.scrollY
+      window.scrollTo({ top: elementTop - headerHeight, behavior: 'smooth' })
+    }
+  }
+
+  // Scroll to section and clear URL param after navigation
+  useEffect(() => {
+    if (sectionParam) {
+      // Scroll to the section after a short delay to allow render
+      setTimeout(() => {
+        scrollToSection(sectionParam)
+        // Clear the search param from URL
+        setSearchParams({}, { replace: true })
+      }, 100)
+    }
+  }, [sectionParam, setSearchParams])
+
+  // Load section data when expanded (lazy loading)
+  const loadSectionData = async (section: string) => {
+    if (loadedSections.has(section)) return
+
+    setLoadedSections((prev) => new Set(prev).add(section))
+
+    switch (section) {
+      case 'connection':
+        await fetchConnection()
+        if (!loadedSections.has('_settings')) {
+          setLoadedSections((prev) => new Set(prev).add('_settings'))
+          await fetchSettings()
+        }
+        break
+      case 'application':
+      case 'mqtt':
+      case 'autoplay':
+      case 'stillsands':
+      case 'homing':
+      case 'clearing':
+      case 'security':
+        // These all share settings data
+        if (!loadedSections.has('_settings')) {
+          setLoadedSections((prev) => new Set(prev).add('_settings'))
+          await fetchSettings()
+        }
+        if ((section === 'autoplay' || section === 'clearing') && !loadedSections.has('_playlists')) {
+          setLoadedSections((prev) => new Set(prev).add('_playlists'))
+          await fetchPlaylists()
+        }
+        if ((section === 'autoplay' || section === 'stillsands') && !loadedSections.has('_board')) {
+          setLoadedSections((prev) => new Set(prev).add('_board'))
+          await fetchBoardSettings()
+        }
+        if (section === 'clearing' && !loadedSections.has('_patterns')) {
+          setLoadedSections((prev) => new Set(prev).add('_patterns'))
+          await fetchPatternFiles()
+        }
+        break
+      case 'led':
+        await fetchLedConfig()
+        break
+      case 'version':
+        await fetchVersionInfo()
+        break
+    }
+  }
+
+  const fetchPatternFiles = async () => {
+    try {
+      const data = await apiClient.get<string[]>('/list_theta_rho_files')
+      // Response is a flat array of file paths
+      setPatternFiles(Array.isArray(data) ? data : [])
+    } catch (error) {
+      console.error('Error fetching pattern files:', error)
+    }
+  }
+
+  const fetchVersionInfo = async () => {
+    try {
+      const data = await apiClient.get<{ current: string; latest: string; update_available: boolean }>('/api/version')
+      setVersionInfo(data)
+    } catch (error) {
+      console.error('Failed to fetch version info:', error)
+    }
+  }
+
+  // Handle accordion open/close and trigger data loading
+  const handleAccordionChange = (values: string[]) => {
+    // Find newly opened section
+    const newlyOpened = values.find((v) => !openSections.includes(v))
+
+    setOpenSections(values)
+
+    // Load data for newly opened sections
+    values.forEach((section) => {
+      if (!loadedSections.has(section)) {
+        loadSectionData(section)
+      }
+    })
+
+    // Scroll newly opened section into view
+    if (newlyOpened) {
+      setTimeout(() => {
+        scrollToSection(newlyOpened)
+      }, 100)
+    }
+  }
+
+  // Load initial section data
+  useEffect(() => {
+    openSections.forEach((section) => {
+      loadSectionData(section)
+    })
+  }, [])
+
+  const fetchConnection = async () => {
+    try {
+      // The backend reports the configured board URL as the single "port".
+      const statusData = await apiClient.get<{ connected: boolean; port?: string }>('/serial_status')
+      setIsConnected(statusData.connected || false)
+      setConnectionStatus(
+        statusData.connected ? `Connected to ${statusData.port || 'board'}` : 'Disconnected'
+      )
+      if (statusData.port) {
+        setBoardAddress((prev) => prev || statusData.port || '')
+      } else {
+        const urls = await apiClient.get<string[]>('/list_serial_ports')
+        if (urls?.[0]) setBoardAddress((prev) => prev || urls[0])
+      }
+    } catch (error) {
+      console.error('Error fetching connection status:', error)
+    }
+  }
+
+  // Always fetch connection state on mount since connection is the default section
+  useEffect(() => {
+    fetchConnection()
+  }, [])
+
+  // Refetch when backend reconnects
+  useOnBackendConnected(() => {
+    fetchConnection()
+  })
+
+  const fetchSettings = async () => {
+    try {
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      const data = await apiClient.get<Record<string, any>>('/api/settings')
+      // Map the nested API response to our flat Settings interface
+      setSettings({
+        app_name: data.app?.name,
+        custom_logo: data.app?.custom_logo,
+        // Homing settings
+        homing_mode: data.homing?.mode,
+        angular_offset: data.homing?.angular_offset_degrees,
+        home_on_connect: data.homing?.home_on_connect,
+        auto_home_enabled: data.homing?.auto_home_enabled,
+        auto_home_after_patterns: data.homing?.auto_home_after_patterns,
+        hard_reset_theta: data.homing?.hard_reset_theta,
+        // Pattern clearing settings
+        clear_pattern_speed: data.patterns?.clear_pattern_speed,
+        custom_clear_from_in: data.patterns?.custom_clear_from_in,
+        custom_clear_from_out: data.patterns?.custom_clear_from_out,
+      })
+      // Set still sands settings
+      if (data.scheduled_pause) {
+        setStillSandsSettings({
+          enabled: data.scheduled_pause.enabled || false,
+          finish_pattern: data.scheduled_pause.finish_pattern || false,
+          control_wled: data.scheduled_pause.control_wled || false,
+          timezone: data.scheduled_pause.timezone || '',
+          time_slots: data.scheduled_pause.time_slots || [],
+        })
+      }
+      // Set security settings
+      if (data.security) {
+        setSecurityMode(data.security.mode || 'off')
+        setHasExistingPassword(data.security.has_password || false)
+      }
+      // Set MQTT config from the same response
+      if (data.mqtt) {
+        setMqttConfig({
+          enabled: data.mqtt.enabled || false,
+          broker: data.mqtt.broker,
+          port: data.mqtt.port,
+          username: data.mqtt.username,
+          device_name: data.mqtt.device_name,
+          device_id: data.mqtt.device_id,
+          client_id: data.mqtt.client_id,
+          discovery_prefix: data.mqtt.discovery_prefix,
+        })
+      }
+    } catch (error) {
+      console.error('Error fetching settings:', error)
+    }
+  }
+
+  const fetchLedConfig = async () => {
+    try {
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      const data = await apiClient.get<Record<string, any>>('/get_led_config')
+      setLedConfig({
+        provider: data.provider || 'none',
+        wled_ip: data.wled_ip,
+      })
+    } catch (error) {
+      console.error('Error fetching LED config:', error)
+    }
+  }
+
+  const fetchBoardSettings = async () => {
+    try {
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      const data = await apiClient.get<Record<string, any>>('/api/board/settings')
+      setBoardReachable(!!data.reachable)
+      if (!data.reachable) return
+      if (data.time) setBoardTime(data.time)
+      if (data.autostart) {
+        const a = data.autostart
+        const pauseSeconds = a.pause_seconds ?? 0
+        const { value, unit } = secondsToDisplayPause(pauseSeconds)
+        setAutoPlayPauseValue(value)
+        setAutoPlayPauseInput(String(value))
+        setAutoPlayPauseUnit(unit)
+        setAutoPlaySettings({
+          enabled: !!a.playlist,
+          playlist: a.playlist || '',
+          run_mode: a.run_mode === 'single' ? 'single' : 'loop',
+          pause_time: pauseSeconds,
+          pause_from_start: !!a.pause_from_start,
+          clear_pattern: a.clear_pattern || 'none',
+          shuffle: !!a.shuffle,
+        })
+      }
+    } catch (error) {
+      console.error('Error fetching board settings:', error)
+      setBoardReachable(false)
+    }
+  }
+
+  const fetchPlaylists = async () => {
+    try {
+      const data = await apiClient.get('/list_all_playlists')
+      // Backend returns array directly, not { playlists: [...] }
+      setPlaylists(Array.isArray(data) ? data : [])
+    } catch (error) {
+      console.error('Error fetching playlists:', error)
+    }
+  }
+
+  const handleConnect = async () => {
+    if (!boardAddress.trim()) {
+      toast.error('Enter the table IP or hostname')
+      return
+    }
+    setIsLoading('connect')
+    try {
+      const data = await apiClient.post<{ success?: boolean; message?: string }>('/connect', { port: boardAddress.trim() })
+      if (data.success) {
+        setIsConnected(true)
+        setConnectionStatus(`Connected to ${boardAddress.trim()}`)
+        toast.success('Connected to the table')
+      } else {
+        throw new Error(data.message || 'Connection failed')
+      }
+    } catch (error) {
+      toast.error('Could not reach the table at that address')
+    } finally {
+      setIsLoading(null)
+    }
+  }
+
+  const handleDisconnect = async () => {
+    setIsLoading('disconnect')
+    try {
+      const data = await apiClient.post<{ success?: boolean }>('/disconnect')
+      if (data.success) {
+        setIsConnected(false)
+        setConnectionStatus('Disconnected')
+        toast.success('Disconnected')
+      }
+    } catch (error) {
+      toast.error('Failed to disconnect')
+    } finally {
+      setIsLoading(null)
+    }
+  }
+
+  const handleSaveAppName = async () => {
+    setIsLoading('appName')
+    try {
+      await apiClient.patch('/api/settings', { app: { name: settings.app_name } })
+      toast.success('App name saved. Refresh to see changes.')
+    } catch (error) {
+      toast.error('Failed to save app name')
+    } finally {
+      setIsLoading(null)
+    }
+  }
+
+  // Update favicon links in the document head and notify Layout to refresh
+  const updateBranding = (customLogo: string | null) => {
+    const timestamp = Date.now() // Cache buster
+
+    // Update favicon links (use apiClient.getAssetUrl for multi-table support)
+    const faviconIco = document.getElementById('favicon-ico') as HTMLLinkElement
+    const appleTouchIcon = document.getElementById('apple-touch-icon') as HTMLLinkElement
+
+    if (customLogo) {
+      if (faviconIco) faviconIco.href = apiClient.getAssetUrl(`/static/custom/favicon.ico?v=${timestamp}`)
+      if (appleTouchIcon) appleTouchIcon.href = apiClient.getAssetUrl(`/static/custom/${customLogo}?v=${timestamp}`)
+    } else {
+      if (faviconIco) faviconIco.href = apiClient.getAssetUrl(`/static/favicon.ico?v=${timestamp}`)
+      if (appleTouchIcon) appleTouchIcon.href = apiClient.getAssetUrl(`/static/apple-touch-icon.png?v=${timestamp}`)
+    }
+
+    // Dispatch event for Layout to update header logo
+    window.dispatchEvent(new CustomEvent('branding-updated'))
+  }
+
+  const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
+    const file = e.target.files?.[0]
+    if (!file) return
+
+    setIsLoading('logo')
+    try {
+      const data = await apiClient.uploadFile('/api/upload-logo', file, 'file') as { filename: string }
+      setSettings({ ...settings, custom_logo: data.filename })
+      updateBranding(data.filename)
+      toast.success('Logo uploaded!')
+    } catch (error) {
+      toast.error(error instanceof Error ? error.message : 'Failed to upload logo')
+    } finally {
+      setIsLoading(null)
+      // Reset the input
+      e.target.value = ''
+    }
+  }
+
+  const handleDeleteLogo = async () => {
+    if (!confirm('Remove custom logo and revert to default?')) return
+
+    setIsLoading('logo')
+    try {
+      await apiClient.delete('/api/custom-logo')
+      setSettings({ ...settings, custom_logo: undefined })
+      updateBranding(null)
+      toast.success('Logo removed!')
+    } catch (error) {
+      toast.error('Failed to remove logo')
+    } finally {
+      setIsLoading(null)
+    }
+  }
+
+  const handleSaveLedConfig = async () => {
+    setIsLoading('led')
+    try {
+      await apiClient.post('/set_led_config', {
+        provider: ledConfig.provider,
+        ip_address: ledConfig.wled_ip,
+      })
+      toast.success('LED configuration saved')
+    } catch (error) {
+      toast.error(error instanceof Error ? error.message : 'Failed to save LED config')
+    } finally {
+      setIsLoading(null)
+    }
+  }
+
+  const handleSaveMqttConfig = async () => {
+    setIsLoading('mqtt')
+    try {
+      await apiClient.patch('/api/settings', {
+        mqtt: {
+          enabled: mqttConfig.enabled,
+          broker: mqttConfig.broker,
+          port: mqttConfig.port,
+          username: mqttConfig.username,
+          password: mqttConfig.password,
+          device_name: mqttConfig.device_name,
+          device_id: mqttConfig.device_id,
+          client_id: mqttConfig.client_id,
+          discovery_prefix: mqttConfig.discovery_prefix,
+        },
+      })
+      toast.success('MQTT configuration saved. Restart required.')
+    } catch (error) {
+      toast.error('Failed to save MQTT config')
+    } finally {
+      setIsLoading(null)
+    }
+  }
+
+  const handleTestMqttConnection = async () => {
+    if (!mqttConfig.broker) {
+      toast.error('Please enter a broker address')
+      return
+    }
+    setIsLoading('mqttTest')
+    try {
+      const data = await apiClient.post<{ success?: boolean; error?: string }>('/api/mqtt-test', {
+        broker: mqttConfig.broker,
+        port: mqttConfig.port || 1883,
+        username: mqttConfig.username || '',
+        password: mqttConfig.password || '',
+      })
+      if (data.success) {
+        toast.success('MQTT connection successful!')
+      } else {
+        toast.error(data.error || 'Connection failed')
+      }
+    } catch (error) {
+      toast.error('Failed to test MQTT connection')
+    } finally {
+      setIsLoading(null)
+    }
+  }
+
+  const handleSaveHomingConfig = async () => {
+    setIsLoading('homing')
+    try {
+      await apiClient.patch('/api/settings', {
+        homing: {
+          mode: settings.homing_mode,
+          angular_offset_degrees: settings.angular_offset,
+          home_on_connect: settings.home_on_connect,
+          auto_home_enabled: settings.auto_home_enabled,
+          auto_home_after_patterns: settings.auto_home_after_patterns,
+          hard_reset_theta: settings.hard_reset_theta,
+        },
+      })
+      toast.success('Homing configuration saved')
+    } catch (error) {
+      toast.error('Failed to save homing configuration')
+    } finally {
+      setIsLoading(null)
+    }
+  }
+
+  const handleSaveClearingSettings = async () => {
+    setIsLoading('clearing')
+    try {
+      await apiClient.patch('/api/settings', {
+        patterns: {
+          // Send 0 to indicate "reset to default" - backend interprets 0 or negative as None
+          clear_pattern_speed: settings.clear_pattern_speed ?? 0,
+          custom_clear_from_in: settings.custom_clear_from_in || null,
+          custom_clear_from_out: settings.custom_clear_from_out || null,
+        },
+      })
+      toast.success('Clearing settings saved')
+    } catch (error) {
+      toast.error('Failed to save clearing settings')
+    } finally {
+      setIsLoading(null)
+    }
+  }
+
+  const handleSaveAutoPlaySettings = async () => {
+    if (autoPlaySettings.enabled && !autoPlaySettings.playlist) {
+      toast.error('Choose a startup playlist first')
+      return
+    }
+    setIsLoading('autoplay')
+    try {
+      const pauseTimeSeconds = displayPauseToSeconds(autoPlayPauseValue, autoPlayPauseUnit)
+      // Stored on the board: an empty playlist disables auto-play on boot.
+      await apiClient.patch('/api/board/settings', {
+        autostart: {
+          playlist: autoPlaySettings.enabled ? autoPlaySettings.playlist : '',
+          run_mode: autoPlaySettings.run_mode,
+          shuffle: autoPlaySettings.shuffle,
+          pause_seconds: pauseTimeSeconds,
+          pause_from_start: autoPlaySettings.pause_from_start,
+          clear_pattern: autoPlaySettings.clear_pattern,
+        },
+      })
+      toast.success('Auto-play saved to the table')
+    } catch (error) {
+      toast.error('Could not save — is the table connected?')
+    } finally {
+      setIsLoading(null)
+    }
+  }
+
+  const handleSaveStillSandsSettings = async () => {
+    setIsLoading('stillsands')
+    try {
+      // The backend also pushes these to the table's own quiet-hours settings
+      // ($Sands/*), so playlists started on the table honor the same schedule.
+      await apiClient.patch('/api/settings', {
+        scheduled_pause: stillSandsSettings,
+      })
+      toast.success('Still Sands settings saved')
+    } catch (error) {
+      toast.error('Failed to save Still Sands settings')
+    } finally {
+      setIsLoading(null)
+    }
+  }
+
+  const handleSyncBoardTime = async () => {
+    setIsLoading('synctime')
+    try {
+      const data = await apiClient.post<{ success: boolean; time?: BoardTime }>('/api/board/sync_time')
+      if (data.time) setBoardTime(data.time)
+      toast.success('Table clock synced')
+    } catch (error) {
+      toast.error('Could not sync the table clock')
+    } finally {
+      setIsLoading(null)
+    }
+  }
+
+  const addTimeSlot = () => {
+    setStillSandsSettings({
+      ...stillSandsSettings,
+      time_slots: [
+        ...stillSandsSettings.time_slots,
+        { start_time: '22:00', end_time: '06:00', days: 'daily', custom_days: [] },
+      ],
+    })
+  }
+
+  const removeTimeSlot = (index: number) => {
+    setStillSandsSettings({
+      ...stillSandsSettings,
+      time_slots: stillSandsSettings.time_slots.filter((_, i) => i !== index),
+    })
+  }
+
+  const updateTimeSlot = (index: number, updates: Partial<TimeSlot>) => {
+    const newSlots = [...stillSandsSettings.time_slots]
+    newSlots[index] = { ...newSlots[index], ...updates }
+    setStillSandsSettings({ ...stillSandsSettings, time_slots: newSlots })
+  }
+
+  return (
+    <div className="flex flex-col w-full max-w-5xl mx-auto gap-6 py-3 sm:py-6 px-0 sm:px-4">
+      {/* Page Header */}
+      <div className="space-y-0.5 sm:space-y-1 pl-1">
+        <h1 className="text-xl font-semibold tracking-tight">Settings</h1>
+        <p className="text-xs text-muted-foreground">
+          Configure your sand table
+        </p>
+      </div>
+
+      <Separator />
+
+      <Accordion
+        type="multiple"
+        value={openSections}
+        onValueChange={handleAccordionChange}
+        className="space-y-3"
+      >
+        {/* Table Connection */}
+        <AccordionItem value="connection" id="section-connection" className="border rounded-lg px-4 overflow-visible bg-card">
+          <AccordionTrigger className="hover:no-underline">
+            <div className="flex items-center gap-3">
+              <span className="material-icons-outlined text-muted-foreground">
+                wifi
+              </span>
+              <div className="text-left">
+                <div className="font-semibold">Table Connection</div>
+                <div className="text-sm text-muted-foreground font-normal">
+                  Controller board address
+                </div>
+              </div>
+            </div>
+          </AccordionTrigger>
+          <AccordionContent className="pt-4 pb-6 space-y-6">
+            {/* Connection Status */}
+            <div className="flex items-center justify-between p-4 rounded-lg border">
+              <div className="flex items-center gap-3">
+                <div className={`w-10 h-10 flex items-center justify-center rounded-lg ${isConnected ? 'bg-green-100 dark:bg-green-900' : 'bg-muted'}`}>
+                  <span className={`material-icons ${isConnected ? 'text-green-600' : 'text-muted-foreground'}`}>
+                    {isConnected ? 'wifi' : 'wifi_off'}
+                  </span>
+                </div>
+                <div>
+                  <p className="font-medium">Status</p>
+                  <p className={`text-sm ${isConnected ? 'text-green-600' : 'text-destructive'}`}>
+                    {connectionStatus}
+                  </p>
+                </div>
+              </div>
+              {isConnected && (
+                <Button
+                  variant="destructive"
+                  size="sm"
+                  onClick={handleDisconnect}
+                  disabled={isLoading === 'disconnect'}
+                >
+                  Disconnect
+                </Button>
+              )}
+            </div>
+
+            {/* Board address */}
+            <div className="space-y-3">
+              <Label htmlFor="board-address">Table address</Label>
+              <div className="flex gap-3">
+                <Input
+                  id="board-address"
+                  value={boardAddress}
+                  onChange={(e) => setBoardAddress(e.target.value)}
+                  placeholder="IP or host (e.g. 192.168.68.160)"
+                  autoCapitalize="none"
+                  autoCorrect="off"
+                  className="flex-1"
+                />
+                <Button
+                  onClick={handleConnect}
+                  disabled={isLoading === 'connect' || !boardAddress.trim()}
+                  className="gap-2"
+                >
+                  {isLoading === 'connect' ? (
+                    <span className="material-icons-outlined animate-spin">sync</span>
+                  ) : (
+                    <span className="material-icons-outlined">cable</span>
+                  )}
+                  Connect
+                </Button>
+              </div>
+              <p className="text-xs text-muted-foreground">
+                The FluidNC controller inside the table, on the same network as this server.
+                The address is saved and reconnected automatically on startup.
+              </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>
+
+        {/* Homing Configuration */}
+        <AccordionItem value="homing" id="section-homing" className="border rounded-lg px-4 overflow-visible bg-card">
+          <AccordionTrigger className="hover:no-underline">
+            <div className="flex items-center gap-3">
+              <span className="material-icons-outlined text-muted-foreground">
+                home
+              </span>
+              <div className="text-left">
+                <div className="font-semibold">Homing Configuration</div>
+                <div className="text-sm text-muted-foreground font-normal">
+                  Homing mode and auto-home settings
+                </div>
+              </div>
+            </div>
+          </AccordionTrigger>
+          <AccordionContent className="pt-4 pb-6 space-y-6">
+            {/* Homing Mode Selection */}
+            <div className="space-y-3">
+              <Label>Homing Mode</Label>
+              <RadioGroup
+                value={String(settings.homing_mode || 0)}
+                onValueChange={(value) =>
+                  setSettings({ ...settings, homing_mode: parseInt(value) })
+                }
+                className="space-y-3"
+              >
+                <div className="flex items-start gap-3 p-3 border rounded-lg cursor-pointer hover:bg-muted/50">
+                  <RadioGroupItem value="0" id="homing-crash" className="mt-0.5" />
+                  <div className="flex-1">
+                    <Label htmlFor="homing-crash" className="font-medium cursor-pointer">
+                      Crash Homing
+                    </Label>
+                    <p className="text-xs text-muted-foreground mt-1">
+                      Y axis moves until physical stop, then theta and rho set to 0
+                    </p>
+                  </div>
+                </div>
+                <div className="flex items-start gap-3 p-3 border rounded-lg cursor-pointer hover:bg-muted/50">
+                  <RadioGroupItem value="1" id="homing-sensor" className="mt-0.5" />
+                  <div className="flex-1">
+                    <Label htmlFor="homing-sensor" className="font-medium cursor-pointer">
+                      Sensor Homing
+                    </Label>
+                    <p className="text-xs text-muted-foreground mt-1">
+                      Homes both X and Y axes using sensors
+                    </p>
+                  </div>
+                </div>
+              </RadioGroup>
+            </div>
+
+            {/* Sensor Offset (only visible for sensor mode) */}
+            {settings.homing_mode === 1 && (
+              <div className="space-y-3">
+                <Label htmlFor="angular-offset">Sensor Offset (degrees)</Label>
+                <Input
+                  id="angular-offset"
+                  type="number"
+                  min="0"
+                  max="360"
+                  step="0.1"
+                  value={settings.angular_offset ?? ''}
+                  onChange={(e) =>
+                    setSettings({
+                      ...settings,
+                      angular_offset: e.target.value === '' ? undefined : parseFloat(e.target.value),
+                    })
+                  }
+                  placeholder="0.0"
+                />
+                <p className="text-xs text-muted-foreground">
+                  Set the angle (in degrees) where your radial arm should be offset. Choose a value so the radial arm points East.
+                </p>
+              </div>
+            )}
+
+            {/* Auto-Home During Playlists */}
+            <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">autorenew</span>
+                    Auto-Home During Playlists
+                  </p>
+                  <p className="text-xs text-muted-foreground mt-1">
+                    Perform homing after a set number of patterns to maintain accuracy
+                  </p>
+                </div>
+                <Switch
+                  checked={settings.auto_home_enabled || false}
+                  onCheckedChange={(checked) =>
+                    setSettings({ ...settings, auto_home_enabled: checked })
+                  }
+                />
+              </div>
+
+              {settings.auto_home_enabled && (
+                <div className="space-y-3">
+                  <Label htmlFor="auto-home-patterns">Home after every X patterns</Label>
+                  <Input
+                    id="auto-home-patterns"
+                    type="number"
+                    min="1"
+                    max="100"
+                    value={settings.auto_home_after_patterns || 5}
+                    onChange={(e) =>
+                      setSettings({
+                        ...settings,
+                        auto_home_after_patterns: parseInt(e.target.value) || 5,
+                      })
+                    }
+                  />
+                  <p className="text-xs text-muted-foreground">
+                    Homing occurs after each main pattern completes (clear patterns don't count).
+                  </p>
+                </div>
+              )}
+            </div>
+
+            {/* Machine Reset on Theta Normalization */}
+            <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">restart_alt</span>
+                    Reset Machine on Theta Normalization
+                  </p>
+                  <p className="text-xs text-muted-foreground mt-1">
+                    Also reset the machine controller when normalizing theta
+                  </p>
+                </div>
+                <Switch
+                  checked={settings.hard_reset_theta || false}
+                  onCheckedChange={(checked) =>
+                    setSettings({ ...settings, hard_reset_theta: checked })
+                  }
+                />
+              </div>
+              <p className="text-xs text-muted-foreground">
+                When disabled (default), theta normalization only adjusts the angle mathematically.
+                When enabled, also resets the machine controller to clear position counters.
+              </p>
+            </div>
+
+            <Button
+              onClick={handleSaveHomingConfig}
+              disabled={isLoading === 'homing'}
+              className="gap-2"
+            >
+              {isLoading === 'homing' ? (
+                <span className="material-icons-outlined animate-spin">sync</span>
+              ) : (
+                <span className="material-icons-outlined">save</span>
+              )}
+              Save Homing Configuration
+            </Button>
+          </AccordionContent>
+        </AccordionItem>
+
+        {/* Application Settings */}
+        <AccordionItem value="application" id="section-application" className="border rounded-lg px-4 overflow-visible 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">Application Settings</div>
+                <div className="text-sm text-muted-foreground font-normal">
+                  Customize app name and branding
+                </div>
+              </div>
+            </div>
+          </AccordionTrigger>
+          <AccordionContent className="pt-4 pb-6 space-y-6">
+            {/* Custom Logo */}
+            <div className="space-y-3">
+              <Label>Custom Logo</Label>
+              <div className="flex flex-col sm:flex-row sm:items-center gap-4 p-4 rounded-lg border">
+                <div className="flex items-center gap-4">
+                  <div className="w-16 h-16 rounded-full overflow-hidden border bg-background flex items-center justify-center shrink-0">
+                    {settings.custom_logo ? (
+                      <img
+                        src={apiClient.getAssetUrl(`/static/custom/${settings.custom_logo}`)}
+                        alt="Custom Logo"
+                        className="w-full h-full object-cover"
+                      />
+                    ) : (
+                      <img
+                        src={apiClient.getAssetUrl('/static/android-chrome-192x192.png')}
+                        alt="Default Logo"
+                        className="w-full h-full object-cover"
+                      />
+                    )}
+                  </div>
+                  <div className="flex-1">
+                    <p className="font-medium">
+                      {settings.custom_logo ? 'Custom logo active' : 'Using default logo'}
+                    </p>
+                    <p className="text-sm text-muted-foreground">
+                      PNG, JPG, GIF, WebP or SVG (max 5MB)
+                    </p>
+                  </div>
+                </div>
+                <div className="flex gap-2 sm:ml-auto">
+                  <Button
+                    variant="secondary"
+                    size="sm"
+                    className="gap-2"
+                    disabled={isLoading === 'logo'}
+                    onClick={() => document.getElementById('logo-upload')?.click()}
+                  >
+                    {isLoading === 'logo' ? (
+                      <span className="material-icons-outlined animate-spin text-base">sync</span>
+                    ) : (
+                      <span className="material-icons-outlined text-base">upload</span>
+                    )}
+                    Upload
+                  </Button>
+                  {settings.custom_logo && (
+                    <Button
+                      variant="secondary"
+                      size="sm"
+                      className="gap-2 text-destructive hover:text-destructive"
+                      disabled={isLoading === 'logo'}
+                      onClick={handleDeleteLogo}
+                    >
+                      <span className="material-icons-outlined text-base">delete</span>
+                    </Button>
+                  )}
+                </div>
+                <input
+                  id="logo-upload"
+                  type="file"
+                  accept=".png,.jpg,.jpeg,.gif,.webp,.svg"
+                  className="hidden"
+                  onChange={handleLogoUpload}
+                />
+              </div>
+              <p className="text-xs text-muted-foreground">
+                A favicon will be automatically generated from your logo.
+              </p>
+            </div>
+
+            <Separator />
+
+            {/* App Name */}
+            <div className="space-y-3">
+              <Label htmlFor="appName">Application Name</Label>
+              <div className="flex gap-3">
+                <div className="relative flex-1">
+                  <Input
+                    id="appName"
+                    value={settings.app_name || ''}
+                    onChange={(e) =>
+                      setSettings({ ...settings, app_name: e.target.value })
+                    }
+                    placeholder="e.g., Dune Weaver"
+                  />
+                  <Button
+                    variant="ghost"
+                    size="sm"
+                    className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 p-0"
+                    onClick={() => setSettings({ ...settings, app_name: 'Dune Weaver' })}
+                  >
+                    <span className="material-icons text-base">restart_alt</span>
+                  </Button>
+                </div>
+                <Button
+                  onClick={handleSaveAppName}
+                  disabled={isLoading === 'appName'}
+                  className="gap-2"
+                >
+                  {isLoading === 'appName' ? (
+                    <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">
+                This name appears in the browser tab and header.
+              </p>
+            </div>
+          </AccordionContent>
+        </AccordionItem>
+
+        {/* Pattern Clearing */}
+        <AccordionItem value="clearing" id="section-clearing" className="border rounded-lg px-4 overflow-visible bg-card">
+          <AccordionTrigger className="hover:no-underline">
+            <div className="flex items-center gap-3">
+              <span className="material-icons-outlined text-muted-foreground">
+                cleaning_services
+              </span>
+              <div className="text-left">
+                <div className="font-semibold">Pattern Clearing</div>
+                <div className="text-sm text-muted-foreground font-normal">
+                  Customize clearing speed and patterns
+                </div>
+              </div>
+            </div>
+          </AccordionTrigger>
+          <AccordionContent className="pt-4 pb-6 space-y-6">
+            <p className="text-sm text-muted-foreground">
+              Customize the clearing behavior used when transitioning between patterns.
+            </p>
+
+            {/* Clearing Speed */}
+            <div className="p-4 rounded-lg border space-y-3">
+              <h4 className="font-medium">Clearing Speed</h4>
+              <p className="text-sm text-muted-foreground">
+                Set a custom speed for clearing patterns. Leave empty to use the default pattern speed.
+              </p>
+              <div className="space-y-3">
+                <Label htmlFor="clear-speed">Speed (steps per minute)</Label>
+                <Input
+                  id="clear-speed"
+                  type="number"
+                  min="50"
+                  max="2000"
+                  step="50"
+                  value={settings.clear_pattern_speed || ''}
+                  onChange={(e) =>
+                    setSettings({
+                      ...settings,
+                      clear_pattern_speed: e.target.value ? parseInt(e.target.value) : undefined,
+                    })
+                  }
+                  placeholder="Default (use pattern speed)"
+                />
+              </div>
+            </div>
+
+            {/* Custom Clear Patterns */}
+            <div className="p-4 rounded-lg border space-y-3">
+              <h4 className="font-medium">Custom Clear Patterns</h4>
+              <p className="text-sm text-muted-foreground">
+                Choose specific patterns to use when clearing. Leave empty for default behavior.
+              </p>
+
+              <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+                <div className="space-y-3">
+                  <Label htmlFor="clear-from-in">Clear From Center Pattern</Label>
+                  <SearchableSelect
+                    value={settings.custom_clear_from_in || '__default__'}
+                    onValueChange={(value) =>
+                      setSettings({ ...settings, custom_clear_from_in: value === '__default__' ? undefined : value })
+                    }
+                    options={[
+                      { value: '__default__', label: 'Default (built-in)' },
+                      ...patternFiles.map((file) => ({ value: file, label: file })),
+                    ]}
+                    placeholder="Default (built-in)"
+                    searchPlaceholder="Search patterns..."
+                    emptyMessage="No patterns found"
+                  />
+                  <p className="text-xs text-muted-foreground">
+                    Pattern used when clearing from center outward.
+                  </p>
+                </div>
+
+                <div className="space-y-3">
+                  <Label htmlFor="clear-from-out">Clear From Perimeter Pattern</Label>
+                  <SearchableSelect
+                    value={settings.custom_clear_from_out || '__default__'}
+                    onValueChange={(value) =>
+                      setSettings({ ...settings, custom_clear_from_out: value === '__default__' ? undefined : value })
+                    }
+                    options={[
+                      { value: '__default__', label: 'Default (built-in)' },
+                      ...patternFiles.map((file) => ({ value: file, label: file })),
+                    ]}
+                    placeholder="Default (built-in)"
+                    searchPlaceholder="Search patterns..."
+                    emptyMessage="No patterns found"
+                  />
+                  <p className="text-xs text-muted-foreground">
+                    Pattern used when clearing from perimeter inward.
+                  </p>
+                </div>
+              </div>
+            </div>
+
+            <Button
+              onClick={handleSaveClearingSettings}
+              disabled={isLoading === 'clearing'}
+              className="gap-2"
+            >
+              {isLoading === 'clearing' ? (
+                <span className="material-icons-outlined animate-spin">sync</span>
+              ) : (
+                <span className="material-icons-outlined">save</span>
+              )}
+              Save Clearing Settings
+            </Button>
+          </AccordionContent>
+        </AccordionItem>
+
+        {/* LED Controller Configuration */}
+        <AccordionItem value="led" id="section-led" className="border rounded-lg px-4 overflow-visible bg-card">
+          <AccordionTrigger className="hover:no-underline">
+            <div className="flex items-center gap-3">
+              <span className="material-icons-outlined text-muted-foreground">
+                lightbulb
+              </span>
+              <div className="text-left">
+                <div className="font-semibold">LED Controller</div>
+                <div className="text-sm text-muted-foreground font-normal">
+                  Table LEDs or WLED control
+                </div>
+              </div>
+            </div>
+          </AccordionTrigger>
+          <AccordionContent className="pt-4 pb-6 space-y-6">
+            {/* LED Provider Selection */}
+            <div className="space-y-3">
+              <Label>LED Provider</Label>
+              <RadioGroup
+                value={ledConfig.provider}
+                onValueChange={(value) =>
+                  setLedConfig({ ...ledConfig, provider: value as LedConfig['provider'] })
+                }
+                className="flex gap-4"
+              >
+                <div className="flex items-center space-x-2">
+                  <RadioGroupItem value="none" id="led-none" />
+                  <Label htmlFor="led-none" className="font-normal">None</Label>
+                </div>
+                <div className="flex items-center space-x-2">
+                  <RadioGroupItem value="board" id="led-board" />
+                  <Label htmlFor="led-board" className="font-normal">Table LEDs (built-in)</Label>
+                </div>
+                <div className="flex items-center space-x-2">
+                  <RadioGroupItem value="wled" id="led-wled" />
+                  <Label htmlFor="led-wled" className="font-normal">WLED</Label>
+                </div>
+              </RadioGroup>
+            </div>
+
+            {/* Board LEDs info */}
+            {ledConfig.provider === 'board' && (
+              <div className="space-y-3 p-4 rounded-lg border">
+                <Alert className="flex items-start">
+                  <span className="material-icons-outlined text-base mr-2 shrink-0">info</span>
+                  <AlertDescription>
+                    The LED ring wired to the table's controller board, driven by the
+                    firmware. Effects, colors and brightness are controlled from the
+                    LED page and persist on the table itself.
+                  </AlertDescription>
+                </Alert>
+              </div>
+            )}
+
+            {/* WLED Config */}
+            {ledConfig.provider === 'wled' && (
+              <div className="space-y-3 p-4 rounded-lg border">
+                <Label htmlFor="wledIp">WLED IP Address</Label>
+                <Input
+                  id="wledIp"
+                  value={ledConfig.wled_ip || ''}
+                  onChange={(e) =>
+                    setLedConfig({ ...ledConfig, wled_ip: e.target.value })
+                  }
+                  placeholder="e.g., 192.168.1.100"
+                />
+                <p className="text-xs text-muted-foreground">
+                  Enter the IP address of your WLED controller
+                </p>
+              </div>
+            )}
+
+            <Button
+              onClick={handleSaveLedConfig}
+              disabled={isLoading === 'led'}
+              className="gap-2"
+            >
+              {isLoading === 'led' ? (
+                <span className="material-icons-outlined animate-spin">sync</span>
+              ) : (
+                <span className="material-icons-outlined">save</span>
+              )}
+              Save LED Configuration
+            </Button>
+          </AccordionContent>
+        </AccordionItem>
+
+        {/* Home Assistant Integration */}
+        <AccordionItem value="mqtt" id="section-mqtt" className="border rounded-lg px-4 overflow-visible bg-card">
+          <AccordionTrigger className="hover:no-underline">
+            <div className="flex items-center gap-3">
+              <span className="material-icons-outlined text-muted-foreground">
+                home
+              </span>
+              <div className="text-left">
+                <div className="font-semibold">Home Assistant Integration</div>
+                <div className="text-sm text-muted-foreground font-normal">
+                  MQTT configuration for smart home control
+                </div>
+              </div>
+            </div>
+          </AccordionTrigger>
+          <AccordionContent className="pt-4 pb-6 space-y-6">
+            {/* Enable Toggle */}
+            <div className="flex items-center justify-between p-4 rounded-lg border">
+              <div>
+                <p className="font-medium">Enable MQTT</p>
+                <p className="text-sm text-muted-foreground">
+                  Connect to Home Assistant via MQTT
+                </p>
+              </div>
+              <Switch
+                checked={mqttConfig.enabled}
+                onCheckedChange={(checked) =>
+                  setMqttConfig({ ...mqttConfig, enabled: checked })
+                }
+              />
+            </div>
+
+            {mqttConfig.enabled && (
+              <div className="space-y-3">
+                {/* Broker Settings */}
+                <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+                  <div className="space-y-3">
+                    <Label htmlFor="mqttBroker">
+                      Broker Address <span className="text-destructive">*</span>
+                    </Label>
+                    <Input
+                      id="mqttBroker"
+                      value={mqttConfig.broker || ''}
+                      onChange={(e) =>
+                        setMqttConfig({ ...mqttConfig, broker: e.target.value })
+                      }
+                      placeholder="e.g., 192.168.1.100"
+                    />
+                  </div>
+                  <div className="space-y-3">
+                    <Label htmlFor="mqttPort">Port</Label>
+                    <Input
+                      id="mqttPort"
+                      type="number"
+                      value={mqttConfig.port || 1883}
+                      onChange={(e) =>
+                        setMqttConfig({ ...mqttConfig, port: parseInt(e.target.value) })
+                      }
+                      placeholder="1883"
+                    />
+                  </div>
+                </div>
+
+                {/* Authentication */}
+                <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+                  <div className="space-y-3">
+                    <Label htmlFor="mqttUser">Username</Label>
+                    <Input
+                      id="mqttUser"
+                      value={mqttConfig.username || ''}
+                      onChange={(e) =>
+                        setMqttConfig({ ...mqttConfig, username: e.target.value })
+                      }
+                      placeholder="Optional"
+                    />
+                  </div>
+                  <div className="space-y-3">
+                    <Label htmlFor="mqttPass">Password</Label>
+                    <Input
+                      id="mqttPass"
+                      type="password"
+                      value={mqttConfig.password || ''}
+                      onChange={(e) =>
+                        setMqttConfig({ ...mqttConfig, password: e.target.value })
+                      }
+                      placeholder="Optional"
+                    />
+                  </div>
+                </div>
+
+                <Separator />
+
+                {/* Device Settings */}
+                <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+                  <div className="space-y-3">
+                    <Label htmlFor="mqttDeviceName">Device Name</Label>
+                    <Input
+                      id="mqttDeviceName"
+                      value={mqttConfig.device_name || 'Dune Weaver'}
+                      onChange={(e) =>
+                        setMqttConfig({ ...mqttConfig, device_name: e.target.value })
+                      }
+                    />
+                  </div>
+                  <div className="space-y-3">
+                    <Label htmlFor="mqttDeviceId">Device ID</Label>
+                    <Input
+                      id="mqttDeviceId"
+                      value={mqttConfig.device_id || 'dune_weaver'}
+                      onChange={(e) =>
+                        setMqttConfig({ ...mqttConfig, device_id: e.target.value })
+                      }
+                    />
+                  </div>
+                </div>
+
+                <Alert className="flex items-start">
+                  <span className="material-icons-outlined text-base mr-2 shrink-0">info</span>
+                  <AlertDescription>
+                    MQTT configuration changes require a restart to take effect.
+                  </AlertDescription>
+                </Alert>
+              </div>
+            )}
+
+            <div className="flex flex-wrap gap-3">
+              <Button
+                onClick={handleSaveMqttConfig}
+                disabled={isLoading === 'mqtt'}
+                className="gap-2"
+              >
+                {isLoading === 'mqtt' ? (
+                  <span className="material-icons-outlined animate-spin">sync</span>
+                ) : (
+                  <span className="material-icons-outlined">save</span>
+                )}
+                Save MQTT Configuration
+              </Button>
+              {mqttConfig.enabled && mqttConfig.broker && (
+                <Button
+                  variant="secondary"
+                  onClick={handleTestMqttConnection}
+                  disabled={isLoading === 'mqttTest'}
+                  className="gap-2"
+                >
+                  {isLoading === 'mqttTest' ? (
+                    <span className="material-icons-outlined animate-spin">sync</span>
+                  ) : (
+                    <span className="material-icons-outlined">wifi_tethering</span>
+                  )}
+                  Test Connection
+                </Button>
+              )}
+            </div>
+          </AccordionContent>
+        </AccordionItem>
+
+        {/* Auto-play on Boot */}
+        <AccordionItem value="autoplay" id="section-autoplay" className="border rounded-lg px-4 overflow-visible bg-card">
+          <AccordionTrigger className="hover:no-underline">
+            <div className="flex items-center gap-3">
+              <span className="material-icons-outlined text-muted-foreground">
+                play_circle
+              </span>
+              <div className="text-left">
+                <div className="font-semibold">Auto-play on Boot</div>
+                <div className="text-sm text-muted-foreground font-normal">
+                  Start a playlist when the table powers on
+                </div>
+              </div>
+            </div>
+          </AccordionTrigger>
+          <AccordionContent className="pt-4 pb-6 space-y-6">
+            {!boardReachable && (
+              <Alert>
+                <AlertDescription>
+                  The table is not reachable, so its saved auto-play settings can't be shown.
+                  Connect to the table first (Table Connection above).
+                </AlertDescription>
+              </Alert>
+            )}
+            <div className="flex items-center justify-between p-4 rounded-lg border">
+              <div>
+                <p className="font-medium">Enable Auto-play</p>
+                <p className="text-sm text-muted-foreground">
+                  Automatically start a playlist after the table powers on and homes.
+                  Stored on the table, so it works even when this server is off.
+                </p>
+              </div>
+              <Switch
+                checked={autoPlaySettings.enabled}
+                onCheckedChange={(checked) =>
+                  setAutoPlaySettings({ ...autoPlaySettings, enabled: checked })
+                }
+              />
+            </div>
+
+            {autoPlaySettings.enabled && (
+              <div className="space-y-3 p-4 rounded-lg border">
+                <div className="space-y-3">
+                  <Label>Startup Playlist</Label>
+                  <Select
+                    value={autoPlaySettings.playlist || undefined}
+                    onValueChange={(value) =>
+                      setAutoPlaySettings({ ...autoPlaySettings, playlist: value })
+                    }
+                  >
+                    <SelectTrigger>
+                      <SelectValue placeholder="Select a playlist..." />
+                    </SelectTrigger>
+                    <SelectContent>
+                      {playlists.length === 0 ? (
+                        <div className="py-6 text-center text-sm text-muted-foreground">
+                          No playlists found
+                        </div>
+                      ) : (
+                        playlists.map((playlist) => (
+                          <SelectItem key={playlist} value={playlist}>
+                            {playlist}
+                          </SelectItem>
+                        ))
+                      )}
+                    </SelectContent>
+                  </Select>
+                  <p className="text-xs text-muted-foreground">
+                    Choose which playlist to play when the system starts.
+                  </p>
+                </div>
+
+                <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+                  <div className="space-y-3">
+                    <Label>Run Mode</Label>
+                    <Select
+                      value={autoPlaySettings.run_mode}
+                      onValueChange={(value) =>
+                        setAutoPlaySettings({
+                          ...autoPlaySettings,
+                          run_mode: value as 'single' | 'loop',
+                        })
+                      }
+                    >
+                      <SelectTrigger>
+                        <SelectValue />
+                      </SelectTrigger>
+                      <SelectContent>
+                        <SelectItem value="single">Single (play once)</SelectItem>
+                        <SelectItem value="loop">Loop (repeat forever)</SelectItem>
+                      </SelectContent>
+                    </Select>
+                  </div>
+                  <div className="space-y-3">
+                    <Label>Pause Between Patterns</Label>
+                    <div className="flex gap-2">
+                      <Input
+                        type="text"
+                        inputMode="numeric"
+                        value={autoPlayPauseInput}
+                        onChange={(e) => {
+                          const val = e.target.value.replace(/[^0-9]/g, '')
+                          setAutoPlayPauseInput(val)
+                        }}
+                        onBlur={() => {
+                          const num = Math.max(0, parseInt(autoPlayPauseInput) || 0)
+                          setAutoPlayPauseValue(num)
+                          setAutoPlayPauseInput(String(num))
+                        }}
+                        onKeyDown={(e) => {
+                          if (e.key === 'Enter') {
+                            const num = Math.max(0, parseInt(autoPlayPauseInput) || 0)
+                            setAutoPlayPauseValue(num)
+                            setAutoPlayPauseInput(String(num))
+                          }
+                        }}
+                        className="w-20"
+                      />
+                      <Select
+                        value={autoPlayPauseUnit}
+                        onValueChange={(v) => setAutoPlayPauseUnit(v as 'sec' | 'min' | 'hr')}
+                      >
+                        <SelectTrigger className="w-20">
+                          <SelectValue />
+                        </SelectTrigger>
+                        <SelectContent>
+                          <SelectItem value="sec">sec</SelectItem>
+                          <SelectItem value="min">min</SelectItem>
+                          <SelectItem value="hr">hr</SelectItem>
+                        </SelectContent>
+                      </Select>
+                    </div>
+                  </div>
+                </div>
+
+                <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+                  <div className="space-y-3">
+                    <Label>Clear Pattern</Label>
+                    <Select
+                      value={autoPlaySettings.clear_pattern}
+                      onValueChange={(value) =>
+                        setAutoPlaySettings({ ...autoPlaySettings, clear_pattern: value })
+                      }
+                    >
+                      <SelectTrigger>
+                        <SelectValue />
+                      </SelectTrigger>
+                      <SelectContent>
+                        <SelectItem value="none">None</SelectItem>
+                        <SelectItem value="adaptive">Adaptive</SelectItem>
+                        <SelectItem value="in">Clear From Center</SelectItem>
+                        <SelectItem value="out">Clear From Perimeter</SelectItem>
+                        <SelectItem value="sideway">Clear Sideways</SelectItem>
+                        <SelectItem value="random">Random</SelectItem>
+                      </SelectContent>
+                    </Select>
+                    <p className="text-xs text-muted-foreground">
+                      Pattern to run before each main pattern.
+                    </p>
+                  </div>
+
+                  <div className="flex items-center justify-between">
+                    <div className="flex-1">
+                      <p className="text-sm font-medium">Shuffle Playlist</p>
+                      <p className="text-xs text-muted-foreground">
+                        Randomize pattern order
+                      </p>
+                    </div>
+                    <Switch
+                      checked={autoPlaySettings.shuffle}
+                      onCheckedChange={(checked) =>
+                        setAutoPlaySettings({ ...autoPlaySettings, shuffle: checked })
+                      }
+                    />
+                  </div>
+                </div>
+
+                <div className="flex items-center justify-between">
+                  <div className="flex-1">
+                    <p className="text-sm font-medium">Pause From Start</p>
+                    <p className="text-xs text-muted-foreground">
+                      Measure the gap from each pattern's start, not its end
+                    </p>
+                  </div>
+                  <Switch
+                    checked={autoPlaySettings.pause_from_start}
+                    onCheckedChange={(checked) =>
+                      setAutoPlaySettings({ ...autoPlaySettings, pause_from_start: checked })
+                    }
+                  />
+                </div>
+              </div>
+            )}
+
+            <Button
+              onClick={handleSaveAutoPlaySettings}
+              disabled={isLoading === 'autoplay'}
+              className="gap-2"
+            >
+              {isLoading === 'autoplay' ? (
+                <span className="material-icons-outlined animate-spin">sync</span>
+              ) : (
+                <span className="material-icons-outlined">save</span>
+              )}
+              Save Auto-play Settings
+            </Button>
+          </AccordionContent>
+        </AccordionItem>
+
+        {/* Still Sands */}
+        <AccordionItem value="stillsands" id="section-stillsands" className="border rounded-lg px-4 overflow-visible bg-card">
+          <AccordionTrigger className="hover:no-underline">
+            <div className="flex items-center gap-3">
+              <span className="material-icons-outlined text-muted-foreground">
+                bedtime
+              </span>
+              <div className="text-left">
+                <div className="font-semibold">Still Sands</div>
+                <div className="text-sm text-muted-foreground font-normal">
+                  Schedule quiet periods for your table
+                </div>
+              </div>
+            </div>
+          </AccordionTrigger>
+          <AccordionContent className="pt-4 pb-6 space-y-6">
+            {/* Table clock — quiet hours only fire on the table when its clock is set */}
+            <div className="flex items-center justify-between p-4 rounded-lg border">
+              <div className="flex items-center gap-3">
+                <span className="material-icons-outlined text-muted-foreground">schedule</span>
+                <div>
+                  <p className="font-medium">Table Clock</p>
+                  <p className="text-sm text-muted-foreground">
+                    {boardTime
+                      ? `${boardTime.local || '—'} · ${boardTime.tz || 'no timezone'} · ${boardTime.synced ? 'synced' : 'not set'}`
+                      : 'No clock reported — is the table connected?'}
+                  </p>
+                  {boardTime && !boardTime.synced && (
+                    <p className="text-xs text-destructive mt-1">
+                      The table clock isn't set, so schedules won't fire on the table.
+                      The server syncs it on connect — sync now to set it immediately.
+                    </p>
+                  )}
+                </div>
+              </div>
+              <Button
+                variant="secondary"
+                size="sm"
+                onClick={handleSyncBoardTime}
+                disabled={isLoading === 'synctime'}
+                className="gap-2"
+              >
+                {isLoading === 'synctime' ? (
+                  <span className="material-icons-outlined animate-spin text-base">sync</span>
+                ) : (
+                  <span className="material-icons-outlined text-base">sync</span>
+                )}
+                Sync
+              </Button>
+            </div>
+
+            <div className="flex items-center justify-between p-4 rounded-lg border">
+              <div>
+                <p className="font-medium">Enable Still Sands</p>
+                <p className="text-sm text-muted-foreground">
+                  Pause the table during scheduled quiet periods. Shared with the
+                  table itself, so playlists started from a phone stay quiet too.
+                </p>
+              </div>
+              <Switch
+                checked={stillSandsSettings.enabled}
+                onCheckedChange={(checked) =>
+                  setStillSandsSettings({ ...stillSandsSettings, enabled: checked })
+                }
+              />
+            </div>
+
+            {stillSandsSettings.enabled && (
+              <div className="space-y-3">
+                {/* Options */}
+                <div className="p-4 rounded-lg border space-y-3">
+                  <div className="flex items-center justify-between">
+                    <div className="flex items-center gap-2">
+                      <span className="material-icons-outlined text-base text-muted-foreground">
+                        hourglass_bottom
+                      </span>
+                      <div>
+                        <p className="text-sm font-medium">Finish Current Pattern</p>
+                        <p className="text-xs text-muted-foreground">
+                          Let the current pattern complete before entering still mode
+                        </p>
+                      </div>
+                    </div>
+                    <Switch
+                      checked={stillSandsSettings.finish_pattern}
+                      onCheckedChange={(checked) =>
+                        setStillSandsSettings({ ...stillSandsSettings, finish_pattern: checked })
+                      }
+                    />
+                  </div>
+
+                  <Separator />
+
+                  <div className="flex items-center justify-between">
+                    <div className="flex items-center gap-2">
+                      <span className="material-icons-outlined text-base text-muted-foreground">
+                        lightbulb
+                      </span>
+                      <div>
+                        <p className="text-sm font-medium">Turn Off LEDs</p>
+                        <p className="text-xs text-muted-foreground">
+                          Switch off the lights during still periods (WLED and the table's LED ring)
+                        </p>
+                      </div>
+                    </div>
+                    <Switch
+                      checked={stillSandsSettings.control_wled}
+                      onCheckedChange={(checked) =>
+                        setStillSandsSettings({ ...stillSandsSettings, control_wled: checked })
+                      }
+                    />
+                  </div>
+
+                  {/* Timezone */}
+                  <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 pt-3 border-t">
+                    <div className="flex items-center gap-3">
+                      <span className="material-icons-outlined text-muted-foreground">
+                        schedule
+                      </span>
+                      <div>
+                        <p className="text-sm font-medium">Timezone</p>
+                        <p className="text-xs text-muted-foreground">
+                          Select a timezone for scheduling
+                        </p>
+                      </div>
+                    </div>
+                    <SearchableSelect
+                      value={stillSandsSettings.timezone || ''}
+                      onValueChange={(value) =>
+                        setStillSandsSettings({ ...stillSandsSettings, timezone: value })
+                      }
+                      placeholder="System Default"
+                      searchPlaceholder="Search timezones..."
+                      className="w-full sm:w-[200px]"
+                      options={[
+                        { value: '', label: 'System Default' },
+                        { value: 'Etc/GMT+12', label: 'UTC-12' },
+                        { value: 'Etc/GMT+11', label: 'UTC-11' },
+                        { value: 'Etc/GMT+10', label: 'UTC-10' },
+                        { value: 'Etc/GMT+9', label: 'UTC-9' },
+                        { value: 'Etc/GMT+8', label: 'UTC-8' },
+                        { value: 'Etc/GMT+7', label: 'UTC-7' },
+                        { value: 'Etc/GMT+6', label: 'UTC-6' },
+                        { value: 'Etc/GMT+5', label: 'UTC-5' },
+                        { value: 'Etc/GMT+4', label: 'UTC-4' },
+                        { value: 'Etc/GMT+3', label: 'UTC-3' },
+                        { value: 'Etc/GMT+2', label: 'UTC-2' },
+                        { value: 'Etc/GMT+1', label: 'UTC-1' },
+                        { value: 'UTC', label: 'UTC' },
+                        { value: 'Etc/GMT-1', label: 'UTC+1' },
+                        { value: 'Etc/GMT-2', label: 'UTC+2' },
+                        { value: 'Etc/GMT-3', label: 'UTC+3' },
+                        { value: 'Etc/GMT-4', label: 'UTC+4' },
+                        { value: 'Etc/GMT-5', label: 'UTC+5' },
+                        { value: 'Etc/GMT-6', label: 'UTC+6' },
+                        { value: 'Etc/GMT-7', label: 'UTC+7' },
+                        { value: 'Etc/GMT-8', label: 'UTC+8' },
+                        { value: 'Etc/GMT-9', label: 'UTC+9' },
+                        { value: 'Etc/GMT-10', label: 'UTC+10' },
+                        { value: 'Etc/GMT-11', label: 'UTC+11' },
+                        { value: 'Etc/GMT-12', label: 'UTC+12' },
+                        { value: 'America/New_York', label: 'America/New_York (Eastern)' },
+                        { value: 'America/Chicago', label: 'America/Chicago (Central)' },
+                        { value: 'America/Denver', label: 'America/Denver (Mountain)' },
+                        { value: 'America/Los_Angeles', label: 'America/Los_Angeles (Pacific)' },
+                        { value: 'Europe/London', label: 'Europe/London' },
+                        { value: 'Europe/Paris', label: 'Europe/Paris' },
+                        { value: 'Europe/Berlin', label: 'Europe/Berlin' },
+                        { value: 'Asia/Tokyo', label: 'Asia/Tokyo' },
+                        { value: 'Asia/Shanghai', label: 'Asia/Shanghai' },
+                        { value: 'Asia/Singapore', label: 'Asia/Singapore' },
+                        { value: 'Australia/Sydney', label: 'Australia/Sydney' },
+                      ]}
+                    />
+                  </div>
+                </div>
+
+                {/* Time Slots */}
+                <div className="p-4 rounded-lg border space-y-3">
+                  <div className="flex items-center justify-between">
+                    <h4 className="font-medium">Still Periods</h4>
+                    <Button onClick={addTimeSlot} size="sm" variant="secondary" className="gap-1">
+                      <span className="material-icons text-base">add</span>
+                      Add Period
+                    </Button>
+                  </div>
+
+                  <p className="text-sm text-muted-foreground">
+                    Define time periods when the sands should rest.
+                  </p>
+
+                  {stillSandsSettings.time_slots.length === 0 ? (
+                    <div className="text-center py-6 text-muted-foreground">
+                      <span className="material-icons text-3xl mb-2">schedule</span>
+                      <p className="text-sm">No still periods configured</p>
+                      <p className="text-xs">Click "Add Period" to create one</p>
+                    </div>
+                  ) : (
+                    <div className="space-y-3">
+                      {stillSandsSettings.time_slots.map((slot, index) => (
+                        <div
+                          key={index}
+                          className="p-3 border rounded-lg bg-muted/50 space-y-3 overflow-hidden"
+                        >
+                          <div className="flex items-center justify-between -mr-1">
+                            <span className="text-sm font-medium">Period {index + 1}</span>
+                            <Button
+                              variant="ghost"
+                              size="icon"
+                              onClick={() => removeTimeSlot(index)}
+                              className="h-7 w-7 text-destructive hover:text-destructive"
+                            >
+                              <span className="material-icons text-lg">delete</span>
+                            </Button>
+                          </div>
+
+                          <div className="grid grid-cols-[1fr_1fr] gap-2">
+                            <div className="space-y-1.5 min-w-0 overflow-hidden">
+                              <Label className="text-xs">Start Time</Label>
+                              <Input
+                                type="time"
+                                value={slot.start_time}
+                                onChange={(e) =>
+                                  updateTimeSlot(index, { start_time: e.target.value })
+                                }
+                                className="text-xs w-full"
+                              />
+                            </div>
+                            <div className="space-y-1.5 min-w-0 overflow-hidden">
+                              <Label className="text-xs">End Time</Label>
+                              <Input
+                                type="time"
+                                value={slot.end_time}
+                                onChange={(e) =>
+                                  updateTimeSlot(index, { end_time: e.target.value })
+                                }
+                                className="text-xs w-full"
+                              />
+                            </div>
+                          </div>
+
+                          <div className="space-y-1.5">
+                            <Label className="text-xs">Days</Label>
+                            <Select
+                              value={slot.days}
+                              onValueChange={(value) =>
+                                updateTimeSlot(index, {
+                                  days: value as TimeSlot['days'],
+                                  ...(value !== 'custom' ? { custom_days: [] } : {}),
+                                })
+                              }
+                            >
+                              <SelectTrigger>
+                                <SelectValue />
+                              </SelectTrigger>
+                              <SelectContent>
+                                <SelectItem value="daily">Daily</SelectItem>
+                                <SelectItem value="weekdays">Weekdays</SelectItem>
+                                <SelectItem value="weekends">Weekends</SelectItem>
+                                <SelectItem value="custom">Custom</SelectItem>
+                              </SelectContent>
+                            </Select>
+                          </div>
+
+                          {slot.days === 'custom' && (
+                            <div className="space-y-1.5">
+                              <Label className="text-xs">Select Days</Label>
+                              <div className="flex flex-wrap gap-1.5">
+                                {[
+                                  { key: 'monday', label: 'Mon' },
+                                  { key: 'tuesday', label: 'Tue' },
+                                  { key: 'wednesday', label: 'Wed' },
+                                  { key: 'thursday', label: 'Thu' },
+                                  { key: 'friday', label: 'Fri' },
+                                  { key: 'saturday', label: 'Sat' },
+                                  { key: 'sunday', label: 'Sun' },
+                                ].map((day) => {
+                                  const isSelected = slot.custom_days?.includes(day.key)
+                                  return (
+                                    <button
+                                      key={day.key}
+                                      type="button"
+                                      onClick={() => {
+                                        const currentDays = slot.custom_days || []
+                                        const newDays = isSelected
+                                          ? currentDays.filter((d) => d !== day.key)
+                                          : [...currentDays, day.key]
+                                        updateTimeSlot(index, { custom_days: newDays })
+                                      }}
+                                      className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${
+                                        isSelected
+                                          ? 'bg-primary text-primary-foreground border-primary'
+                                          : 'bg-background text-muted-foreground border-input hover:bg-accent'
+                                      }`}
+                                    >
+                                      {day.label}
+                                    </button>
+                                  )
+                                })}
+                              </div>
+                            </div>
+                          )}
+                        </div>
+                      ))}
+                    </div>
+                  )}
+                </div>
+
+                <Alert className="flex items-start">
+                  <span className="material-icons-outlined text-base mr-2 shrink-0">info</span>
+                  <AlertDescription>
+                    Times are based on the timezone selected above (or system default). Still
+                    periods that span midnight (e.g., 22:00 to 06:00) are supported. Patterns
+                    resume automatically when still periods end.
+                  </AlertDescription>
+                </Alert>
+              </div>
+            )}
+
+            <Button
+              onClick={handleSaveStillSandsSettings}
+              disabled={isLoading === 'stillsands'}
+              className="gap-2"
+            >
+              {isLoading === 'stillsands' ? (
+                <span className="material-icons-outlined animate-spin">sync</span>
+              ) : (
+                <span className="material-icons-outlined">save</span>
+              )}
+              Save Still Sands Settings
+            </Button>
+          </AccordionContent>
+        </AccordionItem>
+
+        {/* Security */}
+        <AccordionItem value="security" id="section-security" className="border rounded-lg px-4 overflow-visible bg-card">
+          <AccordionTrigger className="hover:no-underline">
+            <div className="flex items-center gap-3">
+              <span className="material-icons-outlined text-muted-foreground">
+                lock
+              </span>
+              <div className="text-left">
+                <div className="font-semibold">Security</div>
+                <div className="text-sm text-muted-foreground font-normal">
+                  App lock and access control
+                </div>
+              </div>
+            </div>
+          </AccordionTrigger>
+          <AccordionContent className="pt-4 pb-6 space-y-4">
+            <p className="text-sm text-muted-foreground">
+              Restrict access to the app to prevent unauthorized changes. Useful for shared spaces or when the table is accessible to children.
+            </p>
+
+            {/* Security Mode */}
+            <div className="space-y-3">
+              <Label className="text-sm font-medium">Security Mode</Label>
+              <RadioGroup
+                value={securityMode}
+                onValueChange={(value) => {
+                  const newMode = value as 'off' | 'lockdown' | 'play_only'
+                  if (newMode === 'off' && securityMode !== 'off') {
+                    if (!confirm('Turn off security? This will remove the password and unlock the app.')) return
+                  }
+                  setSecurityMode(newMode)
+                  // Clear password fields when switching modes
+                  setSecurityPassword('')
+                  setSecurityPasswordConfirm('')
+                }}
+                className="space-y-2"
+              >
+                <div className="flex items-start gap-3 p-3 rounded-lg border">
+                  <RadioGroupItem value="off" id="security-off" className="mt-0.5" />
+                  <div>
+                    <Label htmlFor="security-off" className="font-medium cursor-pointer">Off</Label>
+                    <p className="text-sm text-muted-foreground">No restrictions. Anyone can use the app.</p>
+                  </div>
+                </div>
+                <div className="flex items-start gap-3 p-3 rounded-lg border">
+                  <RadioGroupItem value="play_only" id="security-play-only" className="mt-0.5" />
+                  <div>
+                    <Label htmlFor="security-play-only" className="font-medium cursor-pointer">Play Only</Label>
+                    <p className="text-sm text-muted-foreground">Anyone can browse and play patterns. Settings require a password.</p>
+                  </div>
+                </div>
+                <div className="flex items-start gap-3 p-3 rounded-lg border">
+                  <RadioGroupItem value="lockdown" id="security-lockdown" className="mt-0.5" />
+                  <div>
+                    <Label htmlFor="security-lockdown" className="font-medium cursor-pointer">Full Lockdown</Label>
+                    <p className="text-sm text-muted-foreground">Password required to access the entire app.</p>
+                  </div>
+                </div>
+              </RadioGroup>
+            </div>
+
+            {/* Password fields (shown when mode != off) */}
+            {securityMode !== 'off' && (
+              <div className="space-y-3 pt-2">
+                <Separator />
+                {hasExistingPassword && (
+                  <p className="text-sm text-muted-foreground">
+                    A password is currently set. Enter a new password below to change it, or leave blank to keep the existing one.
+                  </p>
+                )}
+                <div className="space-y-2">
+                  <Label htmlFor="security-password">
+                    {hasExistingPassword ? 'New Password' : 'Password'}
+                  </Label>
+                  <Input
+                    id="security-password"
+                    type="password"
+                    placeholder={hasExistingPassword ? 'Leave blank to keep current' : 'Enter password'}
+                    value={securityPassword}
+                    onChange={(e) => setSecurityPassword(e.target.value)}
+                  />
+                </div>
+                <div className="space-y-2">
+                  <Label htmlFor="security-password-confirm">Confirm Password</Label>
+                  <Input
+                    id="security-password-confirm"
+                    type="password"
+                    placeholder="Confirm password"
+                    value={securityPasswordConfirm}
+                    onChange={(e) => setSecurityPasswordConfirm(e.target.value)}
+                  />
+                </div>
+                {securityPassword && securityPasswordConfirm && securityPassword !== securityPasswordConfirm && (
+                  <p className="text-sm text-destructive">Passwords do not match</p>
+                )}
+              </div>
+            )}
+
+            {/* Save button */}
+            <Button
+              onClick={async () => {
+                // Validate
+                if (securityMode !== 'off') {
+                  if (securityPassword && securityPassword !== securityPasswordConfirm) {
+                    toast.error('Passwords do not match')
+                    return
+                  }
+                  if (!hasExistingPassword && !securityPassword) {
+                    toast.error('Please set a password')
+                    return
+                  }
+                }
+
+                setIsLoading('security')
+                try {
+                  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+                  const payload: any = { security: { mode: securityMode } }
+                  if (securityPassword) {
+                    payload.security.password = securityPassword
+                  }
+                  await apiClient.patch('/api/settings', payload)
+                  toast.success('Security settings saved')
+                  setSecurityPassword('')
+                  setSecurityPasswordConfirm('')
+                  setHasExistingPassword(securityMode !== 'off')
+                  // Notify Layout to refetch security state
+                  window.dispatchEvent(new CustomEvent('security-updated'))
+                } catch {
+                  toast.error('Failed to save security settings')
+                } finally {
+                  setIsLoading(null)
+                }
+              }}
+              disabled={
+                isLoading === 'security' ||
+                (securityMode !== 'off' && securityPassword !== '' && securityPassword !== securityPasswordConfirm) ||
+                (securityMode !== 'off' && !hasExistingPassword && !securityPassword)
+              }
+              className="w-full gap-2"
+            >
+              {isLoading === 'security' ? (
+                <span className="material-icons-outlined animate-spin">sync</span>
+              ) : (
+                <span className="material-icons-outlined">save</span>
+              )}
+              Save Security Settings
+            </Button>
+          </AccordionContent>
+        </AccordionItem>
+
+        {/* WiFi */}
+        <AccordionItem value="wifi" id="section-wifi" className="border rounded-lg px-4 overflow-visible bg-card">
+          <AccordionTrigger className="hover:no-underline">
+            <div className="flex items-center gap-3">
+              <span className="material-icons-outlined text-muted-foreground">
+                wifi
+              </span>
+              <div className="text-left">
+                <div className="font-semibold">WiFi</div>
+                <div className="text-sm text-muted-foreground font-normal">
+                  Network connection settings
+                </div>
+              </div>
+            </div>
+          </AccordionTrigger>
+          <AccordionContent className="pt-4 pb-6 space-y-3">
+            <p className="text-sm text-muted-foreground">
+              Manage WiFi connections, scan for networks, and configure hotspot mode.
+            </p>
+            <Button
+              variant="outline"
+              className="w-full gap-2"
+              onClick={() => navigate('/wifi-setup')}
+            >
+              <span className="material-icons-outlined">settings</span>
+              Open WiFi Setup
+            </Button>
+          </AccordionContent>
+        </AccordionItem>
+
+        {/* Software Version */}
+        <AccordionItem value="version" id="section-version" className="border rounded-lg px-4 overflow-visible bg-card">
+          <AccordionTrigger className="hover:no-underline">
+            <div className="flex items-center gap-3">
+              <span className="material-icons-outlined text-muted-foreground">
+                info
+              </span>
+              <div className="text-left">
+                <div className="font-semibold">Software Version</div>
+                <div className="text-sm text-muted-foreground font-normal">
+                  Updates and system information
+                </div>
+              </div>
+            </div>
+          </AccordionTrigger>
+          <AccordionContent className="pt-4 pb-6 space-y-3">
+            <div className="flex items-center gap-4 p-4 rounded-lg bg-muted/50">
+              <div className="w-10 h-10 flex items-center justify-center bg-background rounded-lg">
+                <span className="material-icons text-muted-foreground">terminal</span>
+              </div>
+              <div className="flex-1">
+                <p className="font-medium">Current Version</p>
+                <p className="text-sm text-muted-foreground">
+                  {versionInfo?.current ? `v${versionInfo.current}` : 'Loading...'}
+                </p>
+              </div>
+            </div>
+
+            <div className="flex items-center gap-4 p-4 rounded-lg bg-muted/50">
+              <div className="w-10 h-10 flex items-center justify-center bg-background rounded-lg">
+                <span className="material-icons text-muted-foreground">system_update</span>
+              </div>
+              <div className="flex-1">
+                <p className="font-medium">Latest Version</p>
+                <p className={`text-sm ${versionInfo?.update_available ? 'text-green-600 dark:text-green-400 font-medium' : 'text-muted-foreground'}`}>
+                  {versionInfo?.latest ? (
+                    <a
+                      href={`https://github.com/tuanchris/dune-weaver/releases/tag/v${versionInfo.latest}`}
+                      target="_blank"
+                      rel="noopener noreferrer"
+                      className="underline underline-offset-2 hover:opacity-80 transition-opacity"
+                    >
+                      v{versionInfo.latest}
+                    </a>
+                  ) : 'Checking...'}
+                  {versionInfo?.update_available && ' (Update available!)'}
+                </p>
+              </div>
+            </div>
+
+            {versionInfo?.update_available && (
+              <Button onClick={() => setUpdateDialogOpen(true)} className="w-full">
+                <span className="material-icons text-base mr-2">system_update</span>
+                Update Now
+              </Button>
+            )}
+
+            <UpdateDialog
+              open={updateDialogOpen}
+              onOpenChange={setUpdateDialogOpen}
+              currentVersion={versionInfo?.current || ''}
+              latestVersion={versionInfo?.latest || ''}
+            />
+          </AccordionContent>
+        </AccordionItem>
+      </Accordion>
+    </div>
+  )
+}

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

@@ -1,914 +0,0 @@
-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>
-  )
-}

+ 682 - 858
frontend/src/pages/TableControlPage.tsx

@@ -1,858 +1,682 @@
-import { useState, useEffect, useRef } from 'react'
-import { toast } from 'sonner'
-import { Button } from '@/components/ui/button'
-import {
-  Card,
-  CardContent,
-  CardDescription,
-  CardHeader,
-  CardTitle,
-} from '@/components/ui/card'
-import { Input } from '@/components/ui/input'
-import { Separator } from '@/components/ui/separator'
-import { Badge } from '@/components/ui/badge'
-import { Alert, AlertDescription } from '@/components/ui/alert'
-import {
-  Dialog,
-  DialogContent,
-  DialogDescription,
-  DialogHeader,
-  DialogTitle,
-  DialogTrigger,
-  DialogFooter,
-} from '@/components/ui/dialog'
-import {
-  Tooltip,
-  TooltipContent,
-  TooltipProvider,
-  TooltipTrigger,
-} from '@/components/ui/tooltip'
-import {
-  Select,
-  SelectContent,
-  SelectItem,
-  SelectTrigger,
-  SelectValue,
-} from '@/components/ui/select'
-import { apiClient } from '@/lib/apiClient'
-import { useStatusStore } from '@/stores/useStatusStore'
-
-export function TableControlPage() {
-  const [speedInput, setSpeedInput] = useState('')
-  const [currentSpeed, setCurrentSpeed] = useState<number | null>(null)
-  const [currentTheta, setCurrentTheta] = useState(0)
-  const [isLoading, setIsLoading] = useState<string | null>(null)
-
-  // Subscribe to shared status WebSocket via store
-  const speed = useStatusStore((s) => s.status?.speed ?? null)
-  const isPatternRunning = useStatusStore((s) =>
-    (s.status?.is_running || s.status?.is_paused) ?? false
-  )
-
-  // Sync speed from store into local state (for the badge display)
-  useEffect(() => {
-    if (speed !== null) setCurrentSpeed(speed)
-  }, [speed])
-
-  // Serial terminal state
-  const [serialPorts, setSerialPorts] = useState<string[]>([])
-  const [selectedSerialPort, setSelectedSerialPort] = useState('')
-  const [serialConnected, setSerialConnected] = useState(false)
-  const [serialCommand, setSerialCommand] = useState('')
-  const [serialHistory, setSerialHistory] = useState<Array<{ type: 'cmd' | 'resp' | 'error'; text: string; time: string }>>([])
-  const [serialLoading, setSerialLoading] = useState(false)
-  const serialOutputRef = useRef<HTMLDivElement>(null)
-  const serialInputRef = useRef<HTMLInputElement>(null)
-
-  const handleAction = async (
-    action: string,
-    endpoint: string,
-    body?: object
-  ) => {
-    setIsLoading(action)
-    try {
-      const data = await apiClient.post<{ success?: boolean; detail?: string }>(endpoint, body)
-      if (data.success !== false) {
-        return { success: true, data }
-      }
-      throw new Error(data.detail || 'Action failed')
-    } catch (error) {
-      console.error(`Error with ${action}:`, error)
-      throw error
-    } finally {
-      setIsLoading(null)
-    }
-  }
-
-  // Helper to check if pattern is running and show warning
-  const checkPatternRunning = (actionName: string): boolean => {
-    if (isPatternRunning) {
-      toast.error(`Cannot ${actionName} while a pattern is running. Stop the pattern first.`, {
-        action: {
-          label: 'Stop',
-          onClick: () => handleStop(),
-        },
-      })
-      return true
-    }
-    return false
-  }
-
-  const handleHome = async () => {
-    try {
-      await handleAction('home', '/send_home')
-      toast.success('Moving to home position...')
-    } catch {
-      toast.error('Failed to move to home position')
-    }
-  }
-
-  const handleStop = async () => {
-    try {
-      await handleAction('stop', '/stop_execution')
-      toast.success('Execution stopped')
-    } catch {
-      // Normal stop failed, try force stop
-      try {
-        await handleAction('stop', '/force_stop')
-        toast.success('Force stopped')
-      } catch {
-        toast.error('Failed to stop execution')
-      }
-    }
-  }
-
-  const handleReset = async () => {
-    try {
-      await handleAction('reset', '/soft_reset')
-      toast.success('Reset sent. Please home the table.')
-    } catch {
-      toast.error('Failed to send reset command')
-    }
-  }
-
-  const handleMoveToCenter = async () => {
-    if (checkPatternRunning('move to center')) return
-    try {
-      await handleAction('center', '/move_to_center')
-      toast.success('Moving to center...')
-    } catch {
-      toast.error('Failed to move to center')
-    }
-  }
-
-  const handleMoveToPerimeter = async () => {
-    if (checkPatternRunning('move to perimeter')) return
-    try {
-      await handleAction('perimeter', '/move_to_perimeter')
-      toast.success('Moving to perimeter...')
-    } catch {
-      toast.error('Failed to move to perimeter')
-    }
-  }
-
-  const handleSetSpeed = async () => {
-    const speed = parseFloat(speedInput)
-    if (isNaN(speed) || speed <= 0) {
-      toast.error('Please enter a valid speed value')
-      return
-    }
-    try {
-      await handleAction('speed', '/set_speed', { speed })
-      setCurrentSpeed(speed)
-      toast.success(`Speed set to ${speed} mm/s`)
-      setSpeedInput('')
-    } catch {
-      toast.error('Failed to set speed')
-    }
-  }
-
-  const handleClearPattern = async (patternFile: string, label: string) => {
-    try {
-      await handleAction(patternFile, '/run_theta_rho', {
-        file_name: patternFile,
-        pre_execution: 'none',
-      })
-      toast.success(`Running ${label}...`)
-    } catch (error) {
-      if (error instanceof Error && error.message.includes('409')) {
-        toast.error('Another pattern is already running')
-      } else {
-        toast.error(`Failed to run ${label}`)
-      }
-    }
-  }
-
-  const handleRotate = async (degrees: number) => {
-    if (checkPatternRunning('align')) return
-    try {
-      const radians = degrees * (Math.PI / 180)
-      const newTheta = currentTheta + radians
-      await handleAction('rotate', '/send_coordinate', { theta: newTheta, rho: 1 })
-      setCurrentTheta(newTheta)
-      toast.info(`Rotated ${degrees}°`)
-    } catch {
-      toast.error('Failed to rotate')
-    }
-  }
-
-  // Serial terminal functions
-  const fetchSerialPorts = async () => {
-    try {
-      const data = await apiClient.get<string[]>('/list_serial_ports')
-      setSerialPorts(Array.isArray(data) ? data : [])
-    } catch {
-      toast.error('Failed to fetch serial ports')
-    }
-  }
-
-  const fetchMainConnectionStatus = async () => {
-    try {
-      // Fetch available ports first to validate against
-      const portsData = await apiClient.get<string[]>('/list_serial_ports')
-      const availablePorts = Array.isArray(portsData) ? portsData : []
-
-      const data = await apiClient.get<{ connected: boolean; port?: string }>('/serial_status')
-      if (data.connected && data.port) {
-        // Only set port if it exists in available ports
-        // This prevents race conditions where stale port data from a different
-        // backend (e.g., Mac port on a Pi) could be set and auto-connected
-        if (availablePorts.includes(data.port)) {
-          setSelectedSerialPort(data.port)
-        } else {
-          console.warn(`Port ${data.port} from status not in available ports, ignoring`)
-        }
-      }
-    } catch {
-      // Ignore errors
-    }
-  }
-
-  const handleSerialConnect = async (silent = false) => {
-    if (!selectedSerialPort) {
-      if (!silent) toast.error('Please select a serial port')
-      return
-    }
-    setSerialLoading(true)
-    try {
-      await apiClient.post('/api/debug-serial/open', { port: selectedSerialPort })
-      setSerialConnected(true)
-      addSerialHistory('resp', `Connected to ${selectedSerialPort}`)
-      if (!silent) toast.success(`Connected to ${selectedSerialPort}`)
-    } catch (error) {
-      const errorMsg = error instanceof Error ? error.message : 'Unknown error'
-      addSerialHistory('error', `Failed to connect: ${errorMsg}`)
-      if (!silent) toast.error('Failed to connect to serial port')
-    } finally {
-      setSerialLoading(false)
-    }
-  }
-
-  const handleSerialDisconnect = async () => {
-    setSerialLoading(true)
-    try {
-      await apiClient.post('/api/debug-serial/close', { port: selectedSerialPort })
-      setSerialConnected(false)
-      addSerialHistory('resp', 'Disconnected')
-      toast.success('Disconnected from serial port')
-    } catch {
-      toast.error('Failed to disconnect')
-    } finally {
-      setSerialLoading(false)
-    }
-  }
-
-  const addSerialHistory = (type: 'cmd' | 'resp' | 'error', text: string) => {
-    const time = new Date().toLocaleTimeString()
-    setSerialHistory((prev) => [...prev.slice(-200), { type, text, time }])
-    setTimeout(() => {
-      if (serialOutputRef.current) {
-        serialOutputRef.current.scrollTop = serialOutputRef.current.scrollHeight
-      }
-    }, 10)
-  }
-
-  const handleSerialSend = async () => {
-    if (!serialCommand.trim() || !serialConnected || serialLoading) return
-
-    const cmd = serialCommand.trim()
-    setSerialCommand('')
-    setSerialLoading(true)
-    addSerialHistory('cmd', cmd)
-
-    try {
-      const data = await apiClient.post<{ responses?: string[]; detail?: string }>('/api/debug-serial/send', { port: selectedSerialPort, command: cmd })
-      if (data.responses) {
-        if (data.responses.length > 0) {
-          data.responses.forEach((line: string) => addSerialHistory('resp', line))
-        } else {
-          addSerialHistory('resp', '(no response)')
-        }
-      } else if (data.detail) {
-        addSerialHistory('error', data.detail || 'Command failed')
-      }
-    } catch (error) {
-      addSerialHistory('error', `Error: ${error}`)
-    } finally {
-      setSerialLoading(false)
-      setTimeout(() => serialInputRef.current?.focus(), 0)
-    }
-  }
-
-  const handleSerialReset = async () => {
-    if (!serialConnected || serialLoading) return
-
-    setSerialLoading(true)
-    addSerialHistory('cmd', '[Soft Reset]')
-
-    try {
-      // Send soft reset command (backend auto-detects: $Bye for FluidNC, Ctrl+X for GRBL)
-      const data = await apiClient.post<{ responses?: string[]; detail?: string }>('/api/debug-serial/send', { port: selectedSerialPort, command: '\x18' })
-      if (data.responses && data.responses.length > 0) {
-        data.responses.forEach((line: string) => addSerialHistory('resp', line))
-      } else {
-        addSerialHistory('resp', 'Reset sent')
-      }
-      toast.success('Reset command sent')
-    } catch (error) {
-      addSerialHistory('error', `Reset failed: ${error}`)
-      toast.error('Failed to send reset')
-    } finally {
-      setSerialLoading(false)
-    }
-  }
-
-  const handleSerialKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
-    if (e.key === 'Enter' && !e.shiftKey) {
-      e.preventDefault()
-      if (!serialLoading) {
-        handleSerialSend()
-      }
-    }
-  }
-
-  // Fetch serial ports and main connection status on mount
-  useEffect(() => {
-    fetchSerialPorts()
-    fetchMainConnectionStatus()
-  }, [])
-
-  return (
-    <TooltipProvider>
-      <div className="flex flex-col w-full max-w-5xl mx-auto gap-6 py-3 sm:py-6 px-0 sm:px-4">
-        {/* Page Header */}
-        <div className="space-y-0.5 sm:space-y-1 pl-1">
-          <h1 className="text-xl font-semibold tracking-tight">Table Control</h1>
-          <p className="text-xs text-muted-foreground">
-            Manual controls for your sand table
-          </p>
-        </div>
-
-        <Separator />
-
-        {/* Main Controls Grid - 2x2 */}
-        <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
-          {/* Primary Actions */}
-          <Card className="transition-all duration-200 hover:shadow-md hover:border-primary/20">
-            <CardHeader className="pb-3">
-              <CardTitle className="text-lg">Primary Actions</CardTitle>
-              <CardDescription>Calibrate or stop the table</CardDescription>
-            </CardHeader>
-            <CardContent>
-              <div className="grid grid-cols-3 gap-3">
-                <Tooltip>
-                  <TooltipTrigger asChild>
-                    <Button
-                      onClick={handleHome}
-                      disabled={isLoading === 'home'}
-                      variant="primary"
-                      className="h-16 gap-1 flex-col items-center justify-center"
-                    >
-                      {isLoading === 'home' ? (
-                        <span className="material-icons-outlined animate-spin text-2xl">sync</span>
-                      ) : (
-                        <span className="material-icons-outlined text-2xl">home</span>
-                      )}
-                      <span className="text-xs">Home</span>
-                    </Button>
-                  </TooltipTrigger>
-                  <TooltipContent>Return to home position</TooltipContent>
-                </Tooltip>
-
-                <Tooltip>
-                  <TooltipTrigger asChild>
-                    <Button
-                      onClick={handleStop}
-                      disabled={isLoading === 'stop'}
-                      variant="destructive"
-                      className="h-16 gap-1 flex-col items-center justify-center"
-                    >
-                      {isLoading === 'stop' ? (
-                        <span className="material-icons-outlined animate-spin text-2xl">sync</span>
-                      ) : (
-                        <span className="material-icons-outlined text-2xl">stop_circle</span>
-                      )}
-                      <span className="text-xs">Stop</span>
-                    </Button>
-                  </TooltipTrigger>
-                  <TooltipContent>Gracefully stop</TooltipContent>
-                </Tooltip>
-
-                <Dialog>
-                  <Tooltip>
-                    <TooltipTrigger asChild>
-                      <DialogTrigger asChild>
-                        <Button
-                          disabled={isLoading === 'reset'}
-                          variant="secondary"
-                          className="h-16 gap-1 flex-col items-center justify-center"
-                        >
-                          {isLoading === 'reset' ? (
-                            <span className="material-icons-outlined animate-spin text-2xl">sync</span>
-                          ) : (
-                            <span className="material-icons-outlined text-2xl">restart_alt</span>
-                          )}
-                          <span className="text-xs">Reset</span>
-                        </Button>
-                      </DialogTrigger>
-                    </TooltipTrigger>
-                    <TooltipContent>Send soft reset to controller</TooltipContent>
-                  </Tooltip>
-                  <DialogContent className="sm:max-w-md">
-                    <DialogHeader>
-                      <DialogTitle>Reset Controller?</DialogTitle>
-                      <DialogDescription>
-                        This will send a soft reset to the controller.
-                      </DialogDescription>
-                    </DialogHeader>
-                    <Alert className="flex items-center border-amber-500/50">
-                      <span className="material-icons-outlined text-amber-500 text-base mr-2 shrink-0">warning</span>
-                      <AlertDescription className="text-amber-600 dark:text-amber-400">
-                        Homing is required after resetting. The table will lose its position reference.
-                      </AlertDescription>
-                    </Alert>
-                    <DialogFooter className="gap-2 sm:gap-0">
-                      <DialogTrigger asChild>
-                        <Button variant="outline">Cancel</Button>
-                      </DialogTrigger>
-                      <DialogTrigger asChild>
-                        <Button variant="destructive" onClick={handleReset}>
-                          Reset Controller
-                        </Button>
-                      </DialogTrigger>
-                    </DialogFooter>
-                  </DialogContent>
-                </Dialog>
-              </div>
-            </CardContent>
-          </Card>
-
-          {/* Speed Control */}
-          <Card className="transition-all duration-200 hover:shadow-md hover:border-primary/20">
-            <CardHeader className="pb-3">
-              <div className="flex items-center justify-between">
-                <div>
-                  <CardTitle className="text-lg">Speed</CardTitle>
-                  <CardDescription>Ball movement speed</CardDescription>
-                </div>
-                <Badge variant="secondary" className="font-mono">
-                  {currentSpeed !== null ? `${currentSpeed} mm/s` : '-- mm/s'}
-                </Badge>
-              </div>
-            </CardHeader>
-            <CardContent>
-              <div className="flex gap-2">
-                <Input
-                  type="number"
-                  value={speedInput}
-                  onChange={(e) => setSpeedInput(e.target.value)}
-                  placeholder="mm/s"
-                  min="1"
-                  step="1"
-                  className="flex-1"
-                  onKeyDown={(e) => e.key === 'Enter' && handleSetSpeed()}
-                />
-                <Button
-                  onClick={handleSetSpeed}
-                  disabled={isLoading === 'speed' || !speedInput}
-                  className="gap-2"
-                >
-                  {isLoading === 'speed' ? (
-                    <span className="material-icons-outlined animate-spin">sync</span>
-                  ) : (
-                    <span className="material-icons-outlined">check</span>
-                  )}
-                  Set
-                </Button>
-              </div>
-            </CardContent>
-          </Card>
-
-          {/* Position */}
-          <Card className="transition-all duration-200 hover:shadow-md hover:border-primary/20">
-            <CardHeader className="pb-3">
-              <CardTitle className="text-lg">Position</CardTitle>
-              <CardDescription>Move ball to a specific location</CardDescription>
-            </CardHeader>
-            <CardContent>
-              <div className="grid grid-cols-3 gap-3">
-                <Tooltip>
-                  <TooltipTrigger asChild>
-                    <Button
-                      onClick={handleMoveToCenter}
-                      disabled={isLoading === 'center'}
-                      variant="secondary"
-                      className="h-16 gap-1 flex-col items-center justify-center"
-                    >
-                      {isLoading === 'center' ? (
-                        <span className="material-icons-outlined animate-spin text-2xl">sync</span>
-                      ) : (
-                        <span className="material-icons-outlined text-2xl">center_focus_strong</span>
-                      )}
-                      <span className="text-xs">Center</span>
-                    </Button>
-                  </TooltipTrigger>
-                  <TooltipContent>Move ball to center</TooltipContent>
-                </Tooltip>
-
-                <Tooltip>
-                  <TooltipTrigger asChild>
-                    <Button
-                      onClick={handleMoveToPerimeter}
-                      disabled={isLoading === 'perimeter'}
-                      variant="secondary"
-                      className="h-16 gap-1 flex-col items-center justify-center"
-                    >
-                      {isLoading === 'perimeter' ? (
-                        <span className="material-icons-outlined animate-spin text-2xl">sync</span>
-                      ) : (
-                        <span className="material-icons-outlined text-2xl">trip_origin</span>
-                      )}
-                      <span className="text-xs">Perimeter</span>
-                    </Button>
-                  </TooltipTrigger>
-                  <TooltipContent>Move ball to edge</TooltipContent>
-                </Tooltip>
-
-                <Dialog>
-                  <Tooltip>
-                    <TooltipTrigger asChild>
-                      <DialogTrigger asChild>
-                        <Button
-                          variant="secondary"
-                          className="h-16 gap-1 flex-col items-center justify-center"
-                        >
-                          <span className="material-icons-outlined text-2xl">screen_rotation</span>
-                          <span className="text-xs">Align</span>
-                        </Button>
-                      </DialogTrigger>
-                    </TooltipTrigger>
-                    <TooltipContent>Align pattern orientation</TooltipContent>
-                  </Tooltip>
-                <DialogContent className="sm:max-w-md">
-                  <DialogHeader>
-                    <DialogTitle>Pattern Orientation Alignment</DialogTitle>
-                    <DialogDescription>
-                      Follow these steps to align your patterns with their previews
-                    </DialogDescription>
-                  </DialogHeader>
-                  <div className="space-y-4 py-4">
-                    <ol className="space-y-3 text-sm">
-                      {[
-                        'Home the table then select move to perimeter. Look at your pattern preview and decide where the "bottom" should be.',
-                        'Manually move the radial arm or use the rotation buttons below to point 90° to the right of where you want the pattern bottom.',
-                        'Click the "Home" button to establish this as the reference position.',
-                        'All patterns will now be oriented according to their previews!',
-                      ].map((step, i) => (
-                        <li key={i} className="flex gap-3">
-                          <Badge
-                            variant="secondary"
-                            className="h-6 w-6 shrink-0 items-center justify-center rounded-full p-0"
-                          >
-                            {i + 1}
-                          </Badge>
-                          <span className="text-muted-foreground">{step}</span>
-                        </li>
-                      ))}
-                    </ol>
-
-                    <Separator />
-
-                    <Alert className="flex items-start border-amber-500/50">
-                      <span className="material-icons-outlined text-amber-500 text-base mr-2 shrink-0">
-                        warning
-                      </span>
-                      <AlertDescription className="text-amber-600 dark:text-amber-400">
-                        Only perform this when you want to change the orientation reference.
-                      </AlertDescription>
-                    </Alert>
-
-                    <div className="space-y-3">
-                      <p className="text-sm font-medium text-center">Fine Adjustment</p>
-                      <div className="flex justify-center gap-2">
-                        <Button
-                          variant="secondary"
-                          onClick={() => handleRotate(-10)}
-                          disabled={isLoading === 'rotate'}
-                        >
-                          <span className="material-icons text-lg mr-1">rotate_left</span>
-                          CCW 10°
-                        </Button>
-                        <Button
-                          variant="secondary"
-                          onClick={() => handleRotate(10)}
-                          disabled={isLoading === 'rotate'}
-                        >
-                          CW 10°
-                          <span className="material-icons text-lg ml-1">rotate_right</span>
-                        </Button>
-                      </div>
-                      <p className="text-xs text-muted-foreground text-center">
-                        Each click rotates 10 degrees
-                      </p>
-                    </div>
-                  </div>
-                  <DialogFooter>
-                    <DialogTrigger asChild>
-                      <Button>Got it</Button>
-                    </DialogTrigger>
-                  </DialogFooter>
-                </DialogContent>
-                </Dialog>
-              </div>
-            </CardContent>
-          </Card>
-
-          {/* Clear Patterns */}
-          <Card className="transition-all duration-200 hover:shadow-md hover:border-primary/20">
-            <CardHeader className="pb-3">
-              <CardTitle className="text-lg">Clear Sand</CardTitle>
-              <CardDescription>Erase current pattern from the table</CardDescription>
-            </CardHeader>
-            <CardContent>
-              <div className="grid grid-cols-3 gap-3">
-                <Tooltip>
-                  <TooltipTrigger asChild>
-                    <Button
-                      onClick={() => handleClearPattern('clear_from_in.thr', 'clear from center')}
-                      disabled={isLoading === 'clear_from_in.thr'}
-                      variant="secondary"
-                      className="h-16 gap-1 flex-col items-center justify-center"
-                    >
-                      {isLoading === 'clear_from_in.thr' ? (
-                        <span className="material-icons-outlined animate-spin text-2xl">sync</span>
-                      ) : (
-                        <span className="material-icons-outlined text-2xl">center_focus_strong</span>
-                      )}
-                      <span className="text-xs">Clear Center</span>
-                    </Button>
-                  </TooltipTrigger>
-                  <TooltipContent>Spiral outward from center</TooltipContent>
-                </Tooltip>
-
-                <Tooltip>
-                  <TooltipTrigger asChild>
-                    <Button
-                      onClick={() => handleClearPattern('clear_from_out.thr', 'clear from perimeter')}
-                      disabled={isLoading === 'clear_from_out.thr'}
-                      variant="secondary"
-                      className="h-16 gap-1 flex-col items-center justify-center"
-                    >
-                      {isLoading === 'clear_from_out.thr' ? (
-                        <span className="material-icons-outlined animate-spin text-2xl">sync</span>
-                      ) : (
-                        <span className="material-icons-outlined text-2xl">all_out</span>
-                      )}
-                      <span className="text-xs">Clear Edge</span>
-                    </Button>
-                  </TooltipTrigger>
-                  <TooltipContent>Spiral inward from edge</TooltipContent>
-                </Tooltip>
-
-                <Tooltip>
-                  <TooltipTrigger asChild>
-                    <Button
-                      onClick={() => handleClearPattern('clear_sideway.thr', 'clear sideways')}
-                      disabled={isLoading === 'clear_sideway.thr'}
-                      variant="secondary"
-                      className="h-16 gap-1 flex-col items-center justify-center"
-                    >
-                      {isLoading === 'clear_sideway.thr' ? (
-                        <span className="material-icons-outlined animate-spin text-2xl">sync</span>
-                      ) : (
-                        <span className="material-icons-outlined text-2xl">swap_horiz</span>
-                      )}
-                      <span className="text-xs">Clear Sideways</span>
-                    </Button>
-                  </TooltipTrigger>
-                  <TooltipContent>Clear with side-to-side motion</TooltipContent>
-                </Tooltip>
-              </div>
-            </CardContent>
-          </Card>
-        </div>
-
-        {/* Serial Terminal */}
-        <Card className="transition-all duration-200 hover:shadow-md hover:border-primary/20">
-          <CardHeader className="pb-3 space-y-3">
-            <div className="flex items-start justify-between gap-2">
-              <div className="min-w-0 space-y-2">
-                <CardTitle className="text-lg flex items-center gap-2">
-                  <span className="material-icons-outlined text-xl">terminal</span>
-                  Serial Terminal
-                </CardTitle>
-                <CardDescription className="hidden sm:block">Send raw commands to the table controller</CardDescription>
-                {/* Warning about pattern interference */}
-                <Alert className="flex items-center border-amber-500/50 py-2">
-                  <span className="material-icons-outlined text-amber-500 text-base mr-2 shrink-0">warning</span>
-                  <AlertDescription className="text-xs text-amber-600 dark:text-amber-400">
-                    Do not use while a pattern is running. This will interfere with the main connection.
-                  </AlertDescription>
-                </Alert>
-              </div>
-              {/* Clear button - only show on desktop in header */}
-              <div className="hidden sm:flex items-center gap-1">
-                {serialHistory.length > 0 && (
-                  <Button
-                    variant="ghost"
-                    size="icon"
-                    onClick={() => setSerialHistory([])}
-                    title="Clear history"
-                  >
-                    <span className="material-icons-outlined">delete_sweep</span>
-                  </Button>
-                )}
-              </div>
-            </div>
-            {/* Controls row - stacks better on mobile */}
-            <div className="flex flex-wrap items-center gap-2">
-              {/* Port selector - auto-refreshes on open */}
-              <Select
-                value={selectedSerialPort}
-                onValueChange={setSelectedSerialPort}
-                onOpenChange={(open) => open && fetchSerialPorts()}
-                disabled={serialConnected || serialLoading}
-              >
-                <SelectTrigger className="h-9 flex-1 min-w-[180px] max-w-[280px]">
-                  <SelectValue placeholder="Select port..." />
-                </SelectTrigger>
-                <SelectContent>
-                  {serialPorts.map((port) => (
-                    <SelectItem key={port} value={port}>{port}</SelectItem>
-                  ))}
-                </SelectContent>
-              </Select>
-              {!serialConnected ? (
-                <Button
-                  size="sm"
-                  onClick={() => handleSerialConnect()}
-                  disabled={!selectedSerialPort || serialLoading}
-                  title="Connect"
-                >
-                  {serialLoading ? (
-                    <span className="material-icons-outlined animate-spin sm:mr-1">sync</span>
-                  ) : (
-                    <span className="material-icons-outlined sm:mr-1">power</span>
-                  )}
-                  <span className="hidden sm:inline">Connect</span>
-                </Button>
-              ) : (
-                <>
-                  <Button
-                    size="sm"
-                    variant="destructive"
-                    onClick={handleSerialDisconnect}
-                    disabled={serialLoading}
-                    title="Disconnect"
-                  >
-                    <span className="material-icons-outlined sm:mr-1">power_off</span>
-                    <span className="hidden sm:inline">Disconnect</span>
-                  </Button>
-                  <Button
-                    size="sm"
-                    variant="secondary"
-                    onClick={handleSerialReset}
-                    disabled={serialLoading}
-                    title="Send soft reset to controller"
-                  >
-                    <span className="material-icons-outlined sm:mr-1">restart_alt</span>
-                    <span className="hidden sm:inline">Reset</span>
-                  </Button>
-                </>
-              )}
-              {/* Clear button - show on mobile in controls row */}
-              {serialHistory.length > 0 && (
-                <Button
-                  variant="ghost"
-                  size="icon"
-                  className="sm:hidden"
-                  onClick={() => setSerialHistory([])}
-                  title="Clear history"
-                >
-                  <span className="material-icons-outlined">delete</span>
-                </Button>
-              )}
-            </div>
-          </CardHeader>
-          <CardContent>
-            {/* Output area */}
-            <div
-              ref={serialOutputRef}
-              className="bg-black/90 rounded-md p-3 h-48 overflow-y-auto font-mono text-sm mb-3"
-            >
-              {serialHistory.length > 0 ? (
-                serialHistory.map((entry, i) => (
-                  <div
-                    key={i}
-                    className={`${
-                      entry.type === 'cmd'
-                        ? 'text-cyan-400'
-                        : entry.type === 'error'
-                          ? 'text-red-400'
-                          : 'text-green-400'
-                    }`}
-                  >
-                    <span className="text-gray-500 text-xs mr-2">{entry.time}</span>
-                    {entry.type === 'cmd' ? '> ' : ''}
-                    {entry.text}
-                  </div>
-                ))
-              ) : (
-                <div className="text-gray-500 italic">
-                  {serialConnected
-                    ? 'Ready. Enter a command below (e.g., $, $$, ?, $H)'
-                    : 'Connect to a serial port to send commands'}
-                </div>
-              )}
-            </div>
-
-            {/* Input area */}
-            <div className="flex gap-2">
-              <Input
-                ref={serialInputRef}
-                value={serialCommand}
-                onChange={(e) => setSerialCommand(e.target.value)}
-                onKeyDown={handleSerialKeyDown}
-                disabled={!serialConnected}
-                readOnly={serialLoading}
-                placeholder={serialConnected ? 'Enter command (e.g., $, $$, ?, $H)' : 'Connect to send commands'}
-                className="font-mono text-base h-11"
-              />
-              <Button
-                onClick={handleSerialSend}
-                disabled={!serialConnected || !serialCommand.trim() || serialLoading}
-                className="h-11 px-6"
-              >
-                {serialLoading ? (
-                  <span className="material-icons-outlined animate-spin">sync</span>
-                ) : (
-                  <span className="material-icons-outlined">send</span>
-                )}
-              </Button>
-            </div>
-          </CardContent>
-        </Card>
-      </div>
-    </TooltipProvider>
-  )
-}
+import { useState, useEffect, useRef } from 'react'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import {
+  Card,
+  CardContent,
+  CardDescription,
+  CardHeader,
+  CardTitle,
+} from '@/components/ui/card'
+import { Input } from '@/components/ui/input'
+import { Separator } from '@/components/ui/separator'
+import { Badge } from '@/components/ui/badge'
+import { Alert, AlertDescription } from '@/components/ui/alert'
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogHeader,
+  DialogTitle,
+  DialogTrigger,
+  DialogFooter,
+} from '@/components/ui/dialog'
+import {
+  Tooltip,
+  TooltipContent,
+  TooltipProvider,
+  TooltipTrigger,
+} from '@/components/ui/tooltip'
+import { apiClient } from '@/lib/apiClient'
+import { useStatusStore } from '@/stores/useStatusStore'
+
+export function TableControlPage() {
+  const [speedInput, setSpeedInput] = useState('')
+  const [currentSpeed, setCurrentSpeed] = useState<number | null>(null)
+  const [currentTheta, setCurrentTheta] = useState(0)
+  const [isLoading, setIsLoading] = useState<string | null>(null)
+
+  // Subscribe to shared status WebSocket via store
+  const speed = useStatusStore((s) => s.status?.speed ?? null)
+  const isPatternRunning = useStatusStore((s) =>
+    (s.status?.is_running || s.status?.is_paused) ?? false
+  )
+
+  // Sync speed from store into local state (for the badge display)
+  useEffect(() => {
+    if (speed !== null) setCurrentSpeed(speed)
+  }, [speed])
+
+  // Serial terminal state
+  const [consoleCommand, setConsoleCommand] = useState('')
+  const [consoleHistory, setConsoleHistory] = useState<Array<{ type: 'cmd' | 'resp' | 'error'; text: string; time: string }>>([])
+  const [consoleLoading, setConsoleLoading] = useState(false)
+  const consoleOutputRef = useRef<HTMLDivElement>(null)
+  const consoleInputRef = useRef<HTMLInputElement>(null)
+
+  const handleAction = async (
+    action: string,
+    endpoint: string,
+    body?: object
+  ) => {
+    setIsLoading(action)
+    try {
+      const data = await apiClient.post<{ success?: boolean; detail?: string }>(endpoint, body)
+      if (data.success !== false) {
+        return { success: true, data }
+      }
+      throw new Error(data.detail || 'Action failed')
+    } catch (error) {
+      console.error(`Error with ${action}:`, error)
+      throw error
+    } finally {
+      setIsLoading(null)
+    }
+  }
+
+  // Helper to check if pattern is running and show warning
+  const checkPatternRunning = (actionName: string): boolean => {
+    if (isPatternRunning) {
+      toast.error(`Cannot ${actionName} while a pattern is running. Stop the pattern first.`, {
+        action: {
+          label: 'Stop',
+          onClick: () => handleStop(),
+        },
+      })
+      return true
+    }
+    return false
+  }
+
+  const handleHome = async () => {
+    try {
+      await handleAction('home', '/send_home')
+      toast.success('Moving to home position...')
+    } catch {
+      toast.error('Failed to move to home position')
+    }
+  }
+
+  const handleStop = async () => {
+    try {
+      await handleAction('stop', '/stop_execution')
+      toast.success('Execution stopped')
+    } catch {
+      // Normal stop failed, try force stop
+      try {
+        await handleAction('stop', '/force_stop')
+        toast.success('Force stopped')
+      } catch {
+        toast.error('Failed to stop execution')
+      }
+    }
+  }
+
+  const handleReset = async () => {
+    try {
+      await handleAction('reset', '/soft_reset')
+      toast.success('Reset sent. Please home the table.')
+    } catch {
+      toast.error('Failed to send reset command')
+    }
+  }
+
+  const handleMoveToCenter = async () => {
+    if (checkPatternRunning('move to center')) return
+    try {
+      await handleAction('center', '/move_to_center')
+      toast.success('Moving to center...')
+    } catch {
+      toast.error('Failed to move to center')
+    }
+  }
+
+  const handleMoveToPerimeter = async () => {
+    if (checkPatternRunning('move to perimeter')) return
+    try {
+      await handleAction('perimeter', '/move_to_perimeter')
+      toast.success('Moving to perimeter...')
+    } catch {
+      toast.error('Failed to move to perimeter')
+    }
+  }
+
+  const handleSetSpeed = async () => {
+    const speed = parseFloat(speedInput)
+    if (isNaN(speed) || speed <= 0) {
+      toast.error('Please enter a valid speed value')
+      return
+    }
+    try {
+      await handleAction('speed', '/set_speed', { speed })
+      setCurrentSpeed(speed)
+      toast.success(`Speed set to ${speed} mm/s`)
+      setSpeedInput('')
+    } catch {
+      toast.error('Failed to set speed')
+    }
+  }
+
+  const handleClearPattern = async (patternFile: string, label: string) => {
+    try {
+      await handleAction(patternFile, '/run_theta_rho', {
+        file_name: patternFile,
+        pre_execution: 'none',
+      })
+      toast.success(`Running ${label}...`)
+    } catch (error) {
+      if (error instanceof Error && error.message.includes('409')) {
+        toast.error('Another pattern is already running')
+      } else {
+        toast.error(`Failed to run ${label}`)
+      }
+    }
+  }
+
+  const handleRotate = async (degrees: number) => {
+    if (checkPatternRunning('align')) return
+    try {
+      const radians = degrees * (Math.PI / 180)
+      const newTheta = currentTheta + radians
+      await handleAction('rotate', '/send_coordinate', { theta: newTheta, rho: 1 })
+      setCurrentTheta(newTheta)
+      toast.info(`Rotated ${degrees}°`)
+    } catch {
+      toast.error('Failed to rotate')
+    }
+  }
+
+  // Board command console: sends $-commands to the FluidNC firmware through
+  // the backend (/api/board/command); output comes from the board's reply and
+  // its rolling session log.
+  const addConsoleHistory = (type: 'cmd' | 'resp' | 'error', text: string) => {
+    const time = new Date().toLocaleTimeString()
+    setConsoleHistory((prev) => [...prev.slice(-200), { type, text, time }])
+    setTimeout(() => {
+      if (consoleOutputRef.current) {
+        consoleOutputRef.current.scrollTop = consoleOutputRef.current.scrollHeight
+      }
+    }, 10)
+  }
+
+  const handleConsoleSend = async () => {
+    if (!consoleCommand.trim() || consoleLoading) return
+
+    const cmd = consoleCommand.trim()
+    setConsoleCommand('')
+    setConsoleLoading(true)
+    addConsoleHistory('cmd', cmd)
+
+    try {
+      const data = await apiClient.post<{ responses?: string[]; log?: string; detail?: string }>(
+        '/api/board/command', { command: cmd })
+      const lines = data.responses ?? []
+      if (lines.length > 0) {
+        lines.forEach((line: string) => addConsoleHistory('resp', line))
+      } else if (data.log) {
+        data.log.split('\n').slice(-5).forEach((line: string) => addConsoleHistory('resp', line))
+      } else {
+        addConsoleHistory('resp', '(no response — check the board log)')
+      }
+    } catch (error) {
+      addConsoleHistory('error', `Error: ${error}`)
+    } finally {
+      setConsoleLoading(false)
+      setTimeout(() => consoleInputRef.current?.focus(), 0)
+    }
+  }
+
+  const handleConsoleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
+    if (e.key === 'Enter' && !e.shiftKey) {
+      e.preventDefault()
+      if (!consoleLoading) {
+        handleConsoleSend()
+      }
+    }
+  }
+
+  return (
+    <TooltipProvider>
+      <div className="flex flex-col w-full max-w-5xl mx-auto gap-6 py-3 sm:py-6 px-0 sm:px-4">
+        {/* Page Header */}
+        <div className="space-y-0.5 sm:space-y-1 pl-1">
+          <h1 className="text-xl font-semibold tracking-tight">Table Control</h1>
+          <p className="text-xs text-muted-foreground">
+            Manual controls for your sand table
+          </p>
+        </div>
+
+        <Separator />
+
+        {/* Main Controls Grid - 2x2 */}
+        <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
+          {/* Primary Actions */}
+          <Card className="transition-all duration-200 hover:shadow-md hover:border-primary/20">
+            <CardHeader className="pb-3">
+              <CardTitle className="text-lg">Primary Actions</CardTitle>
+              <CardDescription>Calibrate or stop the table</CardDescription>
+            </CardHeader>
+            <CardContent>
+              <div className="grid grid-cols-3 gap-3">
+                <Tooltip>
+                  <TooltipTrigger asChild>
+                    <Button
+                      onClick={handleHome}
+                      disabled={isLoading === 'home'}
+                      variant="primary"
+                      className="h-16 gap-1 flex-col items-center justify-center"
+                    >
+                      {isLoading === 'home' ? (
+                        <span className="material-icons-outlined animate-spin text-2xl">sync</span>
+                      ) : (
+                        <span className="material-icons-outlined text-2xl">home</span>
+                      )}
+                      <span className="text-xs">Home</span>
+                    </Button>
+                  </TooltipTrigger>
+                  <TooltipContent>Return to home position</TooltipContent>
+                </Tooltip>
+
+                <Tooltip>
+                  <TooltipTrigger asChild>
+                    <Button
+                      onClick={handleStop}
+                      disabled={isLoading === 'stop'}
+                      variant="destructive"
+                      className="h-16 gap-1 flex-col items-center justify-center"
+                    >
+                      {isLoading === 'stop' ? (
+                        <span className="material-icons-outlined animate-spin text-2xl">sync</span>
+                      ) : (
+                        <span className="material-icons-outlined text-2xl">stop_circle</span>
+                      )}
+                      <span className="text-xs">Stop</span>
+                    </Button>
+                  </TooltipTrigger>
+                  <TooltipContent>Gracefully stop</TooltipContent>
+                </Tooltip>
+
+                <Dialog>
+                  <Tooltip>
+                    <TooltipTrigger asChild>
+                      <DialogTrigger asChild>
+                        <Button
+                          disabled={isLoading === 'reset'}
+                          variant="secondary"
+                          className="h-16 gap-1 flex-col items-center justify-center"
+                        >
+                          {isLoading === 'reset' ? (
+                            <span className="material-icons-outlined animate-spin text-2xl">sync</span>
+                          ) : (
+                            <span className="material-icons-outlined text-2xl">restart_alt</span>
+                          )}
+                          <span className="text-xs">Reset</span>
+                        </Button>
+                      </DialogTrigger>
+                    </TooltipTrigger>
+                    <TooltipContent>Send soft reset to controller</TooltipContent>
+                  </Tooltip>
+                  <DialogContent className="sm:max-w-md">
+                    <DialogHeader>
+                      <DialogTitle>Reset Controller?</DialogTitle>
+                      <DialogDescription>
+                        This will send a soft reset to the controller.
+                      </DialogDescription>
+                    </DialogHeader>
+                    <Alert className="flex items-center border-amber-500/50">
+                      <span className="material-icons-outlined text-amber-500 text-base mr-2 shrink-0">warning</span>
+                      <AlertDescription className="text-amber-600 dark:text-amber-400">
+                        Homing is required after resetting. The table will lose its position reference.
+                      </AlertDescription>
+                    </Alert>
+                    <DialogFooter className="gap-2 sm:gap-0">
+                      <DialogTrigger asChild>
+                        <Button variant="outline">Cancel</Button>
+                      </DialogTrigger>
+                      <DialogTrigger asChild>
+                        <Button variant="destructive" onClick={handleReset}>
+                          Reset Controller
+                        </Button>
+                      </DialogTrigger>
+                    </DialogFooter>
+                  </DialogContent>
+                </Dialog>
+              </div>
+            </CardContent>
+          </Card>
+
+          {/* Speed Control */}
+          <Card className="transition-all duration-200 hover:shadow-md hover:border-primary/20">
+            <CardHeader className="pb-3">
+              <div className="flex items-center justify-between">
+                <div>
+                  <CardTitle className="text-lg">Speed</CardTitle>
+                  <CardDescription>Ball movement speed</CardDescription>
+                </div>
+                <Badge variant="secondary" className="font-mono">
+                  {currentSpeed !== null ? `${currentSpeed} mm/s` : '-- mm/s'}
+                </Badge>
+              </div>
+            </CardHeader>
+            <CardContent>
+              <div className="flex gap-2">
+                <Input
+                  type="number"
+                  value={speedInput}
+                  onChange={(e) => setSpeedInput(e.target.value)}
+                  placeholder="mm/s"
+                  min="1"
+                  step="1"
+                  className="flex-1"
+                  onKeyDown={(e) => e.key === 'Enter' && handleSetSpeed()}
+                />
+                <Button
+                  onClick={handleSetSpeed}
+                  disabled={isLoading === 'speed' || !speedInput}
+                  className="gap-2"
+                >
+                  {isLoading === 'speed' ? (
+                    <span className="material-icons-outlined animate-spin">sync</span>
+                  ) : (
+                    <span className="material-icons-outlined">check</span>
+                  )}
+                  Set
+                </Button>
+              </div>
+            </CardContent>
+          </Card>
+
+          {/* Position */}
+          <Card className="transition-all duration-200 hover:shadow-md hover:border-primary/20">
+            <CardHeader className="pb-3">
+              <CardTitle className="text-lg">Position</CardTitle>
+              <CardDescription>Move ball to a specific location</CardDescription>
+            </CardHeader>
+            <CardContent>
+              <div className="grid grid-cols-3 gap-3">
+                <Tooltip>
+                  <TooltipTrigger asChild>
+                    <Button
+                      onClick={handleMoveToCenter}
+                      disabled={isLoading === 'center'}
+                      variant="secondary"
+                      className="h-16 gap-1 flex-col items-center justify-center"
+                    >
+                      {isLoading === 'center' ? (
+                        <span className="material-icons-outlined animate-spin text-2xl">sync</span>
+                      ) : (
+                        <span className="material-icons-outlined text-2xl">center_focus_strong</span>
+                      )}
+                      <span className="text-xs">Center</span>
+                    </Button>
+                  </TooltipTrigger>
+                  <TooltipContent>Move ball to center</TooltipContent>
+                </Tooltip>
+
+                <Tooltip>
+                  <TooltipTrigger asChild>
+                    <Button
+                      onClick={handleMoveToPerimeter}
+                      disabled={isLoading === 'perimeter'}
+                      variant="secondary"
+                      className="h-16 gap-1 flex-col items-center justify-center"
+                    >
+                      {isLoading === 'perimeter' ? (
+                        <span className="material-icons-outlined animate-spin text-2xl">sync</span>
+                      ) : (
+                        <span className="material-icons-outlined text-2xl">trip_origin</span>
+                      )}
+                      <span className="text-xs">Perimeter</span>
+                    </Button>
+                  </TooltipTrigger>
+                  <TooltipContent>Move ball to edge</TooltipContent>
+                </Tooltip>
+
+                <Dialog>
+                  <Tooltip>
+                    <TooltipTrigger asChild>
+                      <DialogTrigger asChild>
+                        <Button
+                          variant="secondary"
+                          className="h-16 gap-1 flex-col items-center justify-center"
+                        >
+                          <span className="material-icons-outlined text-2xl">screen_rotation</span>
+                          <span className="text-xs">Align</span>
+                        </Button>
+                      </DialogTrigger>
+                    </TooltipTrigger>
+                    <TooltipContent>Align pattern orientation</TooltipContent>
+                  </Tooltip>
+                <DialogContent className="sm:max-w-md">
+                  <DialogHeader>
+                    <DialogTitle>Pattern Orientation Alignment</DialogTitle>
+                    <DialogDescription>
+                      Follow these steps to align your patterns with their previews
+                    </DialogDescription>
+                  </DialogHeader>
+                  <div className="space-y-4 py-4">
+                    <ol className="space-y-3 text-sm">
+                      {[
+                        'Home the table then select move to perimeter. Look at your pattern preview and decide where the "bottom" should be.',
+                        'Manually move the radial arm or use the rotation buttons below to point 90° to the right of where you want the pattern bottom.',
+                        'Click the "Home" button to establish this as the reference position.',
+                        'All patterns will now be oriented according to their previews!',
+                      ].map((step, i) => (
+                        <li key={i} className="flex gap-3">
+                          <Badge
+                            variant="secondary"
+                            className="h-6 w-6 shrink-0 items-center justify-center rounded-full p-0"
+                          >
+                            {i + 1}
+                          </Badge>
+                          <span className="text-muted-foreground">{step}</span>
+                        </li>
+                      ))}
+                    </ol>
+
+                    <Separator />
+
+                    <Alert className="flex items-start border-amber-500/50">
+                      <span className="material-icons-outlined text-amber-500 text-base mr-2 shrink-0">
+                        warning
+                      </span>
+                      <AlertDescription className="text-amber-600 dark:text-amber-400">
+                        Only perform this when you want to change the orientation reference.
+                      </AlertDescription>
+                    </Alert>
+
+                    <div className="space-y-3">
+                      <p className="text-sm font-medium text-center">Fine Adjustment</p>
+                      <div className="flex justify-center gap-2">
+                        <Button
+                          variant="secondary"
+                          onClick={() => handleRotate(-10)}
+                          disabled={isLoading === 'rotate'}
+                        >
+                          <span className="material-icons text-lg mr-1">rotate_left</span>
+                          CCW 10°
+                        </Button>
+                        <Button
+                          variant="secondary"
+                          onClick={() => handleRotate(10)}
+                          disabled={isLoading === 'rotate'}
+                        >
+                          CW 10°
+                          <span className="material-icons text-lg ml-1">rotate_right</span>
+                        </Button>
+                      </div>
+                      <p className="text-xs text-muted-foreground text-center">
+                        Each click rotates 10 degrees
+                      </p>
+                    </div>
+                  </div>
+                  <DialogFooter>
+                    <DialogTrigger asChild>
+                      <Button>Got it</Button>
+                    </DialogTrigger>
+                  </DialogFooter>
+                </DialogContent>
+                </Dialog>
+              </div>
+            </CardContent>
+          </Card>
+
+          {/* Clear Patterns */}
+          <Card className="transition-all duration-200 hover:shadow-md hover:border-primary/20">
+            <CardHeader className="pb-3">
+              <CardTitle className="text-lg">Clear Sand</CardTitle>
+              <CardDescription>Erase current pattern from the table</CardDescription>
+            </CardHeader>
+            <CardContent>
+              <div className="grid grid-cols-3 gap-3">
+                <Tooltip>
+                  <TooltipTrigger asChild>
+                    <Button
+                      onClick={() => handleClearPattern('clear_from_in.thr', 'clear from center')}
+                      disabled={isLoading === 'clear_from_in.thr'}
+                      variant="secondary"
+                      className="h-16 gap-1 flex-col items-center justify-center"
+                    >
+                      {isLoading === 'clear_from_in.thr' ? (
+                        <span className="material-icons-outlined animate-spin text-2xl">sync</span>
+                      ) : (
+                        <span className="material-icons-outlined text-2xl">center_focus_strong</span>
+                      )}
+                      <span className="text-xs">Clear Center</span>
+                    </Button>
+                  </TooltipTrigger>
+                  <TooltipContent>Spiral outward from center</TooltipContent>
+                </Tooltip>
+
+                <Tooltip>
+                  <TooltipTrigger asChild>
+                    <Button
+                      onClick={() => handleClearPattern('clear_from_out.thr', 'clear from perimeter')}
+                      disabled={isLoading === 'clear_from_out.thr'}
+                      variant="secondary"
+                      className="h-16 gap-1 flex-col items-center justify-center"
+                    >
+                      {isLoading === 'clear_from_out.thr' ? (
+                        <span className="material-icons-outlined animate-spin text-2xl">sync</span>
+                      ) : (
+                        <span className="material-icons-outlined text-2xl">all_out</span>
+                      )}
+                      <span className="text-xs">Clear Edge</span>
+                    </Button>
+                  </TooltipTrigger>
+                  <TooltipContent>Spiral inward from edge</TooltipContent>
+                </Tooltip>
+
+                <Tooltip>
+                  <TooltipTrigger asChild>
+                    <Button
+                      onClick={() => handleClearPattern('clear_sideway.thr', 'clear sideways')}
+                      disabled={isLoading === 'clear_sideway.thr'}
+                      variant="secondary"
+                      className="h-16 gap-1 flex-col items-center justify-center"
+                    >
+                      {isLoading === 'clear_sideway.thr' ? (
+                        <span className="material-icons-outlined animate-spin text-2xl">sync</span>
+                      ) : (
+                        <span className="material-icons-outlined text-2xl">swap_horiz</span>
+                      )}
+                      <span className="text-xs">Clear Sideways</span>
+                    </Button>
+                  </TooltipTrigger>
+                  <TooltipContent>Clear with side-to-side motion</TooltipContent>
+                </Tooltip>
+              </div>
+            </CardContent>
+          </Card>
+        </div>
+
+        {/* Board Command Console */}
+        <Card className="transition-all duration-200 hover:shadow-md hover:border-primary/20">
+          <CardHeader className="pb-3 space-y-3">
+            <div className="flex items-start justify-between gap-2">
+              <div className="min-w-0 space-y-2">
+                <CardTitle className="text-lg flex items-center gap-2">
+                  <span className="material-icons-outlined text-xl">terminal</span>
+                  Command Console
+                </CardTitle>
+                <CardDescription className="hidden sm:block">
+                  Send $-commands to the table's firmware (e.g. $Sand/HomingMode, $LED/Effect=fire)
+                </CardDescription>
+                <Alert className="flex items-center border-amber-500/50 py-2">
+                  <span className="material-icons-outlined text-amber-500 text-base mr-2 shrink-0">warning</span>
+                  <AlertDescription className="text-xs text-amber-600 dark:text-amber-400">
+                    For advanced use. Motion commands sent here can interfere with a running pattern.
+                  </AlertDescription>
+                </Alert>
+              </div>
+              {consoleHistory.length > 0 && (
+                <Button
+                  variant="ghost"
+                  size="icon"
+                  onClick={() => setConsoleHistory([])}
+                  title="Clear history"
+                >
+                  <span className="material-icons-outlined">delete_sweep</span>
+                </Button>
+              )}
+            </div>
+          </CardHeader>
+          <CardContent>
+            {/* Output area */}
+            <div
+              ref={consoleOutputRef}
+              className="bg-black/90 rounded-md p-3 h-48 overflow-y-auto font-mono text-sm mb-3"
+            >
+              {consoleHistory.length > 0 ? (
+                consoleHistory.map((entry, i) => (
+                  <div
+                    key={i}
+                    className={`${
+                      entry.type === 'cmd'
+                        ? 'text-cyan-400'
+                        : entry.type === 'error'
+                          ? 'text-red-400'
+                          : 'text-green-400'
+                    }`}
+                  >
+                    <span className="text-gray-500 text-xs mr-2">{entry.time}</span>
+                    {entry.type === 'cmd' ? '> ' : ''}
+                    {entry.text}
+                  </div>
+                ))
+              ) : (
+                <div className="text-gray-500 italic">
+                  Ready. Enter a firmware command (e.g. $Sand/HomingMode, $Playlist/List)
+                </div>
+              )}
+            </div>
+
+            {/* Input area */}
+            <div className="flex gap-2">
+              <Input
+                ref={consoleInputRef}
+                value={consoleCommand}
+                onChange={(e) => setConsoleCommand(e.target.value)}
+                onKeyDown={handleConsoleKeyDown}
+                readOnly={consoleLoading}
+                placeholder="Enter command (e.g. $Sand/HomingMode)"
+                className="font-mono text-base h-11"
+              />
+              <Button
+                onClick={handleConsoleSend}
+                disabled={!consoleCommand.trim() || consoleLoading}
+                className="h-11 px-6"
+              >
+                {consoleLoading ? (
+                  <span className="material-icons-outlined animate-spin">sync</span>
+                ) : (
+                  <span className="material-icons-outlined">send</span>
+                )}
+              </Button>
+            </div>
+          </CardContent>
+        </Card>
+      </div>
+    </TooltipProvider>
+  )
+}

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

@@ -4,8 +4,6 @@ import { apiClient } from '@/lib/apiClient'
 export interface StatusData {
   current_file: string | null
   is_paused: boolean
-  manual_pause: boolean
-  scheduled_pause: boolean
   is_running: boolean
   is_homing: boolean
   is_clearing: boolean
@@ -29,6 +27,9 @@ export interface StatusData {
     next_file: string | null
     files: string[]
     name: string | null
+    // Firmware-side shuffle: the played order is unknown to the host, so the
+    // queue list shows contents, not order.
+    shuffled?: boolean
   } | null
   speed: number
   pause_time_remaining: number

+ 0 - 20
frontend/src/test/mocks/handlers.ts

@@ -222,16 +222,6 @@ export const handlers = [
     return HttpResponse.json({ success: true })
   }),
 
-  http.post('/reorder_playlist', async ({ request }) => {
-    const body = await request.json() as { from_index: number; to_index: number }
-    // Reorder the current queue
-    const queue = [...mockData.status.queue]
-    const [item] = queue.splice(body.from_index, 1)
-    queue.splice(body.to_index, 0, item)
-    mockData.status.queue = queue
-    return HttpResponse.json({ success: true })
-  }),
-
   http.post('/add_to_playlist', async ({ request }) => {
     const body = await request.json() as { playlist_name: string; file_path: string }
     if (!mockData.playlists[body.playlist_name]) {
@@ -241,16 +231,6 @@ export const handlers = [
     return HttpResponse.json({ success: true })
   }),
 
-  http.post('/add_to_queue', async ({ request }) => {
-    const body = await request.json() as { file: string; position?: 'next' | 'end' }
-    if (body.position === 'next') {
-      mockData.status.queue.unshift(body.file)
-    } else {
-      mockData.status.queue.push(body.file)
-    }
-    return HttpResponse.json({ success: true })
-  }),
-
   // ----------------
   // Playback Control Endpoints
   // ----------------

+ 0 - 3
frontend/vite.config.ts

@@ -128,12 +128,9 @@ export default defineConfig({
       '/stop_execution': 'http://localhost:8080',
       '/force_stop': 'http://localhost:8080',
       '/soft_reset': 'http://localhost:8080',
-      '/controller_restart': 'http://localhost:8080',
       '/pause_execution': 'http://localhost:8080',
       '/resume_execution': 'http://localhost:8080',
       '/skip_pattern': 'http://localhost:8080',
-      '/reorder_playlist': 'http://localhost:8080',
-      '/add_to_queue': 'http://localhost:8080',
       '/run_theta_rho': 'http://localhost:8080',
       '/run_playlist': 'http://localhost:8080',
       // Movement

+ 3553 - 4291
main.py

@@ -1,4292 +1,3554 @@
-from fastapi import FastAPI, UploadFile, File, HTTPException, BackgroundTasks, WebSocket, WebSocketDisconnect, Request
-from fastapi.responses import JSONResponse, FileResponse
-from fastapi.staticfiles import StaticFiles
-from fastapi.middleware.cors import CORSMiddleware
-from fastapi.templating import Jinja2Templates
-from pydantic import BaseModel
-from typing import List, Optional
-import os
-import logging
-from datetime import datetime
-from modules.connection import connection_manager
-from modules.core import pattern_manager
-from modules.core.pattern_manager import parse_theta_rho_file, THETA_RHO_DIR
-from modules.core import playlist_manager
-from modules.update import update_manager
-from modules.core.state import state
-from modules import mqtt
-import signal
-import asyncio
-from contextlib import asynccontextmanager
-from modules.led.led_interface import LEDInterface
-from modules.screen.screen_controller import ScreenController
-from modules.led.idle_timeout_manager import idle_timeout_manager
-from modules.core.cache_manager import get_cache_path, generate_image_preview, get_pattern_metadata
-from modules.core.version_manager import version_manager
-from modules.core.mdns_discovery import discovery as mdns_discovery
-from modules.core.log_handler import init_memory_handler, get_memory_handler
-from modules.wifi.router import router as wifi_router, captive_portal_router
-import json
-import base64
-import hashlib
-import time
-import subprocess
-
-# Get log level from environment variable, default to INFO
-log_level_str = os.getenv('LOG_LEVEL', 'INFO').upper()
-log_level = getattr(logging, log_level_str, logging.INFO)
-
-logging.basicConfig(
-    level=log_level,
-    format='%(asctime)s - %(name)s:%(lineno)d - %(levelname)s - %(message)s',
-    handlers=[
-        logging.StreamHandler(),
-    ]
-)
-
-# Initialize memory log handler for web UI log viewer
-# Increased to 5000 entries to support lazy loading in the UI
-init_memory_handler(max_entries=5000)
-
-logger = logging.getLogger(__name__)
-
-
-async def _check_table_is_idle() -> bool:
-    """Helper function to check if table is idle."""
-    return not state.current_playing_file or state.pause_requested
-
-
-def _start_idle_led_timeout():
-    """Start idle LED timeout if enabled."""
-    if not state.dw_led_idle_timeout_enabled or state.dw_led_idle_timeout_minutes <= 0:
-        return
-
-    logger.debug(f"Starting idle LED timeout: {state.dw_led_idle_timeout_minutes} minutes")
-    idle_timeout_manager.start_idle_timeout(
-        timeout_minutes=state.dw_led_idle_timeout_minutes,
-        state=state,
-        check_idle_callback=_check_table_is_idle
-    )
-
-
-def check_homing_in_progress():
-    """Check if homing is in progress and raise exception if so."""
-    if state.is_homing:
-        raise HTTPException(status_code=409, detail="Cannot perform this action while homing is in progress")
-
-
-def normalize_file_path(file_path: str) -> str:
-    """Normalize file path separators for consistent cross-platform handling."""
-    if not file_path:
-        return ''
-    
-    # First normalize path separators
-    normalized = file_path.replace('\\', '/')
-    
-    # Remove only the patterns directory prefix from the beginning, not patterns within the path
-    if normalized.startswith('./patterns/'):
-        normalized = normalized[11:]
-    elif normalized.startswith('patterns/'):
-        normalized = normalized[9:]
-    
-    return normalized
-
-@asynccontextmanager
-async def lifespan(app: FastAPI):
-    # Startup
-    logger.info("Starting Dune Weaver application...")
-
-    # Register signal handlers
-    signal.signal(signal.SIGINT, signal_handler)
-    signal.signal(signal.SIGTERM, signal_handler)
-
-    # Connect device in background so the web server starts immediately
-    async def connect_and_home():
-        """Connect to device and perform homing in background."""
-        try:
-            # Connect without homing first (fast)
-            await asyncio.to_thread(connection_manager.connect_device, False)
-
-            # If connected, perform homing in background (unless disabled)
-            if state.conn and state.conn.is_connected():
-                if not state.home_on_connect:
-                    logger.info("Device connected. Home-on-connect is disabled — skipping automatic homing.")
-                else:
-                    logger.info("Device connected, starting homing in background...")
-                    state.is_homing = True
-                    try:
-                        success = await asyncio.to_thread(connection_manager.home)
-                        if not success:
-                            logger.warning("Background homing failed or was skipped")
-                            # If sensor homing failed, close connection and wait for user action
-                            if state.sensor_homing_failed:
-                                logger.error("Sensor homing failed - closing connection. User must check sensor or switch to crash homing.")
-                                if state.conn:
-                                    await asyncio.to_thread(state.conn.close)
-                                    state.conn = None
-                                return  # Don't proceed with auto-play
-                    finally:
-                        state.is_homing = False
-                        logger.info("Background homing completed")
-
-                # After homing (or skip), check for auto_play mode
-                if state.auto_play_enabled and state.auto_play_playlist:
-                    logger.info(f"Homing complete, checking auto_play playlist: {state.auto_play_playlist}")
-                    try:
-                        playlist_exists = playlist_manager.get_playlist(state.auto_play_playlist) is not None
-                        if not playlist_exists:
-                            logger.warning(f"Auto-play playlist '{state.auto_play_playlist}' not found. Clearing invalid reference.")
-                            state.auto_play_playlist = None
-                            state.save()
-                        elif state.conn and state.conn.is_connected():
-                            logger.info(f"Starting auto-play playlist: {state.auto_play_playlist}")
-                            asyncio.create_task(playlist_manager.run_playlist(
-                                state.auto_play_playlist,
-                                pause_time=state.auto_play_pause_time,
-                                clear_pattern=state.auto_play_clear_pattern,
-                                run_mode=state.auto_play_run_mode,
-                                shuffle=state.auto_play_shuffle
-                            ))
-                    except Exception as e:
-                        logger.error(f"Failed to auto-play playlist: {str(e)}")
-        except Exception as e:
-            logger.warning(f"Failed to auto-connect to serial port: {str(e)}")
-
-    # Start connection/homing in background - doesn't block server startup
-    asyncio.create_task(connect_and_home())
-
-    # Keep app state in sync with the board. During playback the pattern
-    # executor polls /sand_status at a higher rate; this low-rate poller keeps
-    # theta/rho/state fresh while idle (after jogs, homing, board-side changes).
-    async def board_status_poller():
-        while True:
-            try:
-                if state.conn and state.conn.is_connected():
-                    await asyncio.to_thread(connection_manager.poll_status_once)
-            except Exception as e:
-                logger.debug(f"Idle status poll failed: {e}")
-            await asyncio.sleep(2.0)
-
-    asyncio.create_task(board_status_poller())
-
-    # Initialize LED controller based on saved configuration
-    try:
-        # Auto-detect provider for backward compatibility with existing installations
-        if not state.led_provider or state.led_provider == "none":
-            if state.wled_ip:
-                state.led_provider = "wled"
-                logger.info("Auto-detected WLED provider from existing configuration")
-
-        # Initialize the appropriate controller
-        if state.led_provider == "wled" and state.wled_ip:
-            state.led_controller = LEDInterface("wled", state.wled_ip)
-            logger.info(f"LED controller initialized: WLED at {state.wled_ip}")
-        elif state.led_provider == "dw_leds":
-            state.led_controller = LEDInterface(
-                "dw_leds",
-                num_leds=state.dw_led_num_leds,
-                gpio_pin=state.dw_led_gpio_pin,
-                pixel_order=state.dw_led_pixel_order,
-                brightness=state.dw_led_brightness / 100.0,
-                speed=state.dw_led_speed,
-                intensity=state.dw_led_intensity
-            )
-            logger.info(f"LED controller initialized: DW LEDs ({state.dw_led_num_leds} LEDs on GPIO{state.dw_led_gpio_pin}, pixel order: {state.dw_led_pixel_order})")
-
-            # Initialize hardware and start idle effect (matches behavior of /set_led_config)
-            status = state.led_controller.check_status()
-            if status.get("connected", False):
-                if state.led_automation_enabled:
-                    state.led_controller.effect_idle(state.dw_led_idle_effect)
-                    _start_idle_led_timeout()
-                    logger.info("DW LEDs hardware initialized and idle effect started")
-                else:
-                    logger.info("DW LEDs hardware initialized (manual mode, no auto-effect)")
-            else:
-                error_msg = status.get("error", "Unknown error")
-                logger.warning(f"DW LED hardware initialization failed: {error_msg}")
-        else:
-            state.led_controller = None
-            logger.info("LED controller not configured")
-
-        # Save if provider was auto-detected
-        if state.led_provider and state.wled_ip:
-            state.save()
-    except Exception as e:
-        logger.warning(f"Failed to initialize LED controller: {str(e)}")
-        state.led_controller = None
-
-    # Initialize screen controller for LCD backlight control
-    try:
-        state.screen_controller = ScreenController()
-        if state.screen_controller.available:
-            logger.info("Screen controller initialized (backlight control available)")
-        else:
-            logger.info("Screen controller initialized (no backlight device found)")
-    except Exception as e:
-        logger.warning(f"Failed to initialize screen controller: {e}")
-        state.screen_controller = None
-
-    # Note: auto_play is now handled in connect_and_home() after homing completes
-
-    try:
-        mqtt.init_mqtt()
-    except Exception as e:
-        logger.warning(f"Failed to initialize MQTT: {str(e)}")
-    
-    # Schedule cache generation check for later (non-blocking startup)
-    async def delayed_cache_check():
-        """Check and generate cache in background."""
-        try:
-            logger.info("Starting cache check...")
-
-            from modules.core.cache_manager import is_cache_generation_needed_async, generate_cache_background
-
-            if await is_cache_generation_needed_async():
-                logger.info("Cache generation needed, starting background task...")
-                asyncio.create_task(generate_cache_background())  # Don't await - run in background
-            else:
-                logger.info("Cache is up to date, skipping generation")
-        except Exception as e:
-            logger.warning(f"Failed during cache generation: {str(e)}")
-
-    # Start cache check in background immediately
-    asyncio.create_task(delayed_cache_check())
-
-    # Start idle timeout monitor
-    async def idle_timeout_monitor():
-        """Monitor LED idle timeout and turn off LEDs when timeout expires."""
-        import time
-        while True:
-            try:
-                await asyncio.sleep(30)  # Check every 30 seconds
-
-                if not state.dw_led_idle_timeout_enabled:
-                    continue
-
-                if not state.led_automation_enabled:
-                    continue
-
-                if not state.led_controller or not state.led_controller.is_configured:
-                    continue
-
-                # Check if we're currently playing a pattern
-                is_playing = bool(state.current_playing_file or state.current_playlist)
-                if is_playing:
-                    # Reset activity time when playing
-                    state.dw_led_last_activity_time = time.time()
-                    continue
-
-                # If no activity time set, initialize it
-                if state.dw_led_last_activity_time is None:
-                    state.dw_led_last_activity_time = time.time()
-                    continue
-
-                # Calculate idle duration
-                idle_seconds = time.time() - state.dw_led_last_activity_time
-                timeout_seconds = state.dw_led_idle_timeout_minutes * 60
-
-                # Turn off LEDs if timeout expired
-                if idle_seconds >= timeout_seconds:
-                    status = state.led_controller.check_status()
-                    # Check both "power" (WLED) and "power_on" (DW LEDs) keys
-                    is_powered_on = status.get("power", False) or status.get("power_on", False)
-                    if is_powered_on:  # Only turn off if currently on
-                        logger.info(f"Idle timeout ({state.dw_led_idle_timeout_minutes} minutes) expired, turning off LEDs")
-                        state.led_controller.set_power(0)
-                        # Reset activity time to prevent repeated turn-off attempts
-                        state.dw_led_last_activity_time = time.time()
-
-            except Exception as e:
-                logger.error(f"Error in idle timeout monitor: {e}")
-                await asyncio.sleep(60)  # Wait longer on error
-
-    asyncio.create_task(idle_timeout_monitor())
-
-    # Start Still Sands LED monitor for idle table
-    async def still_sands_led_monitor():
-        """Monitor Still Sands transitions when the table is idle and no playlist is running.
-
-        Handles the case where a Still Sands period starts/ends while the table is completely
-        idle (no pattern or playlist active). Without this, LEDs would stay on all night
-        if the table was idle when the quiet period began.
-        """
-        from modules.core.pattern_manager import is_in_scheduled_pause_period, start_idle_led_timeout
-
-        was_in_still_sands = False
-        while True:
-            try:
-                await asyncio.sleep(30)  # Check every 30 seconds
-
-                # Skip if LED control during Still Sands is disabled
-                if not state.scheduled_pause_control_wled:
-                    was_in_still_sands = False
-                    continue
-
-                # Skip if no LED controller configured
-                if not state.led_controller or not state.led_controller.is_configured:
-                    was_in_still_sands = False
-                    continue
-
-                # Skip if a pattern or playlist is actively running —
-                # the pattern_manager handles Still Sands in that case
-                is_playing = bool(state.current_playing_file or state.current_playlist)
-                if is_playing:
-                    was_in_still_sands = False
-                    continue
-
-                in_still_sands = is_in_scheduled_pause_period()
-
-                if in_still_sands and not was_in_still_sands:
-                    # Entering Still Sands while idle — turn off LEDs
-                    status = state.led_controller.check_status()
-                    is_powered_on = status.get("power", False) or status.get("power_on", False)
-                    if is_powered_on:
-                        logger.info("Still Sands period started while idle, turning off LEDs")
-                        state.led_controller.set_power(0)
-                elif not in_still_sands and was_in_still_sands:
-                    # Leaving Still Sands while idle — restore idle effect and restart timeout
-                    if state.led_automation_enabled:
-                        logger.info("Still Sands period ended while idle, restoring idle LED effect")
-                        await start_idle_led_timeout(check_still_sands=False)
-                    else:
-                        logger.info("Manual mode: Still Sands ended, LEDs remain off")
-
-                was_in_still_sands = in_still_sands
-
-            except Exception as e:
-                logger.error(f"Error in Still Sands LED monitor: {e}")
-                await asyncio.sleep(60)  # Wait longer on error
-
-    asyncio.create_task(still_sands_led_monitor())
-
-    # Advertise this table via mDNS and browse for peer tables (best-effort)
-    try:
-        await mdns_discovery.start(
-            table_id=state.table_id,
-            table_name=state.table_name,
-            port=8080,
-            version=await version_manager.get_current_version(),
-        )
-    except Exception as e:
-        logger.warning(f"mDNS table discovery unavailable: {e}")
-
-    yield  # This separates startup from shutdown code
-
-    # Shutdown
-    logger.info("Shutting down Dune Weaver application...")
-    await mdns_discovery.stop()
-
-app = FastAPI(lifespan=lifespan)
-
-# Add CORS middleware to allow cross-origin requests from other Dune Weaver frontends
-# This enables multi-table control from a single frontend
-# Note: allow_credentials must be False when allow_origins=["*"] (browser security requirement)
-app.add_middleware(
-    CORSMiddleware,
-    allow_origins=["*"],  # Allow all origins for local network access
-    allow_credentials=False,
-    allow_methods=["*"],
-    allow_headers=["*"],
-)
-
-templates = Jinja2Templates(directory="templates")
-app.mount("/static", StaticFiles(directory="static"), name="static")
-
-# Include WiFi management router
-app.include_router(wifi_router)
-app.include_router(captive_portal_router)
-
-# Global semaphore to limit concurrent preview processing
-# Prevents resource exhaustion when loading many previews simultaneously
-# Lazily initialized to avoid "attached to a different loop" errors
-_preview_semaphore: Optional[asyncio.Semaphore] = None
-
-def get_preview_semaphore() -> asyncio.Semaphore:
-    """Get or create the preview semaphore in the current event loop."""
-    global _preview_semaphore
-    if _preview_semaphore is None:
-        _preview_semaphore = asyncio.Semaphore(5)
-    return _preview_semaphore
-
-# Pydantic models for request/response validation
-class ConnectRequest(BaseModel):
-    port: Optional[str] = None
-
-class auto_playModeRequest(BaseModel):
-    enabled: bool
-    playlist: Optional[str] = None
-    run_mode: Optional[str] = "loop"
-    pause_time: Optional[float] = 5.0
-    clear_pattern: Optional[str] = "adaptive"
-    shuffle: Optional[bool] = False
-
-class TimeSlot(BaseModel):
-    start_time: str  # HH:MM format
-    end_time: str    # HH:MM format
-    days: str        # "daily", "weekdays", "weekends", or "custom"
-    custom_days: Optional[List[str]] = []  # ["monday", "tuesday", etc.]
-
-class ScheduledPauseRequest(BaseModel):
-    enabled: bool
-    control_wled: Optional[bool] = False
-    finish_pattern: Optional[bool] = False  # Finish current pattern before pausing
-    timezone: Optional[str] = None  # IANA timezone or None for system default
-    time_slots: List[TimeSlot] = []
-
-class CoordinateRequest(BaseModel):
-    theta: float
-    rho: float
-
-class PlaylistRequest(BaseModel):
-    playlist_name: str
-    files: List[str] = []
-    pause_time: float = 0
-    clear_pattern: Optional[str] = None
-    run_mode: str = "single"
-    shuffle: bool = False
-
-class PlaylistRunRequest(BaseModel):
-    playlist_name: str
-    pause_time: Optional[float] = 0
-    clear_pattern: Optional[str] = None
-    run_mode: Optional[str] = "single"
-    shuffle: Optional[bool] = False
-    start_time: Optional[str] = None
-    end_time: Optional[str] = None
-
-class SpeedRequest(BaseModel):
-    speed: float
-
-class WLEDRequest(BaseModel):
-    wled_ip: Optional[str] = None
-
-class LEDConfigRequest(BaseModel):
-    provider: str  # "wled", "dw_leds", or "none"
-    ip_address: Optional[str] = None  # For WLED only
-    # DW LED specific fields
-    num_leds: Optional[int] = None
-    gpio_pin: Optional[int] = None
-    pixel_order: Optional[str] = None
-    brightness: Optional[int] = None
-
-class DeletePlaylistRequest(BaseModel):
-    playlist_name: str
-
-class RenamePlaylistRequest(BaseModel):
-    old_name: str
-    new_name: str
-
-class ThetaRhoRequest(BaseModel):
-    file_name: str
-    pre_execution: Optional[str] = "none"
-
-class GetCoordinatesRequest(BaseModel):
-    file_name: str
-
-# ============================================================================
-# Unified Settings Models
-# ============================================================================
-
-class AppSettingsUpdate(BaseModel):
-    name: Optional[str] = None
-    custom_logo: Optional[str] = None  # Filename or empty string to clear (favicon auto-generated)
-
-class ConnectionSettingsUpdate(BaseModel):
-    preferred_port: Optional[str] = None
-
-class PatternSettingsUpdate(BaseModel):
-    clear_pattern_speed: Optional[int] = None
-    custom_clear_from_in: Optional[str] = None
-    custom_clear_from_out: Optional[str] = None
-
-class AutoPlaySettingsUpdate(BaseModel):
-    enabled: Optional[bool] = None
-    playlist: Optional[str] = None
-    run_mode: Optional[str] = None
-    pause_time: Optional[float] = None
-    clear_pattern: Optional[str] = None
-    shuffle: Optional[bool] = None
-
-class ScheduledPauseSettingsUpdate(BaseModel):
-    enabled: Optional[bool] = None
-    control_wled: Optional[bool] = None
-    finish_pattern: Optional[bool] = None
-    timezone: Optional[str] = None  # IANA timezone (e.g., "America/New_York") or None for system default
-    time_slots: Optional[List[TimeSlot]] = None
-
-class HomingSettingsUpdate(BaseModel):
-    mode: Optional[int] = None
-    angular_offset_degrees: Optional[float] = None
-    home_on_connect: Optional[bool] = None  # Auto-home after connecting on startup
-    auto_home_enabled: Optional[bool] = None
-    auto_home_after_patterns: Optional[int] = None
-    hard_reset_theta: Optional[bool] = None  # Enable hard reset ($Bye) when resetting theta
-
-class DwLedSettingsUpdate(BaseModel):
-    num_leds: Optional[int] = None
-    gpio_pin: Optional[int] = None
-    pixel_order: Optional[str] = None
-    brightness: Optional[int] = None
-    speed: Optional[int] = None
-    intensity: Optional[int] = None
-    idle_effect: Optional[dict] = None
-    playing_effect: Optional[dict] = None
-    idle_timeout_enabled: Optional[bool] = None
-    idle_timeout_minutes: Optional[int] = None
-
-class LedSettingsUpdate(BaseModel):
-    provider: Optional[str] = None  # "none", "wled", "dw_leds"
-    wled_ip: Optional[str] = None
-    control_mode: Optional[str] = None  # "manual" or "automated"
-    dw_led: Optional[DwLedSettingsUpdate] = None
-
-class MqttSettingsUpdate(BaseModel):
-    enabled: Optional[bool] = None
-    broker: Optional[str] = None
-    port: Optional[int] = None
-    username: Optional[str] = None
-    password: Optional[str] = None  # Write-only, never returned in GET
-    client_id: Optional[str] = None
-    discovery_prefix: Optional[str] = None
-    device_id: Optional[str] = None
-    device_name: Optional[str] = None
-
-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):
-    mode: Optional[str] = None  # "off", "lockdown", "play_only"
-    password: Optional[str] = None  # Write-only, stored as SHA-256 hash
-
-class SecurityVerifyRequest(BaseModel):
-    password: str
-
-class SettingsUpdate(BaseModel):
-    """Request model for PATCH /api/settings - all fields optional for partial updates"""
-    app: Optional[AppSettingsUpdate] = None
-    connection: Optional[ConnectionSettingsUpdate] = None
-    patterns: Optional[PatternSettingsUpdate] = None
-    auto_play: Optional[AutoPlaySettingsUpdate] = None
-    scheduled_pause: Optional[ScheduledPauseSettingsUpdate] = None
-    homing: Optional[HomingSettingsUpdate] = None
-    led: Optional[LedSettingsUpdate] = None
-    mqtt: Optional[MqttSettingsUpdate] = None
-    machine: Optional[MachineSettingsUpdate] = None
-    security: Optional[SecuritySettingsUpdate] = None
-
-# Store active WebSocket connections
-active_status_connections = set()
-active_cache_progress_connections = set()
-
-@app.websocket("/ws/status")
-async def websocket_status_endpoint(websocket: WebSocket):
-    await websocket.accept()
-    active_status_connections.add(websocket)
-    try:
-        while True:
-            status = pattern_manager.get_status()
-            try:
-                await websocket.send_json({
-                    "type": "status_update",
-                    "data": status
-                })
-            except RuntimeError as e:
-                if "close message has been sent" in str(e):
-                    break
-                raise
-            # Use longer interval during pattern execution to reduce asyncio overhead
-            # This helps prevent timing-related serial corruption on Pi 3B+
-            interval = 2.0 if state.current_playing_file else 1.0
-            await asyncio.sleep(interval)
-    except WebSocketDisconnect:
-        pass
-    finally:
-        active_status_connections.discard(websocket)
-        try:
-            await websocket.close()
-        except RuntimeError:
-            pass
-
-async def broadcast_status_update(status: dict):
-    """Broadcast status update to all connected clients."""
-    disconnected = set()
-    for websocket in active_status_connections:
-        try:
-            await websocket.send_json({
-                "type": "status_update",
-                "data": status
-            })
-        except WebSocketDisconnect:
-            disconnected.add(websocket)
-        except RuntimeError:
-            disconnected.add(websocket)
-    
-    active_status_connections.difference_update(disconnected)
-
-@app.websocket("/ws/cache-progress")
-async def websocket_cache_progress_endpoint(websocket: WebSocket):
-    from modules.core.cache_manager import get_cache_progress
-
-    await websocket.accept()
-    active_cache_progress_connections.add(websocket)
-    try:
-        while True:
-            progress = get_cache_progress()
-            try:
-                await websocket.send_json({
-                    "type": "cache_progress",
-                    "data": progress
-                })
-            except RuntimeError as e:
-                if "close message has been sent" in str(e):
-                    break
-                raise
-            await asyncio.sleep(1.0)  # Update every 1 second (reduced frequency for better performance)
-    except WebSocketDisconnect:
-        pass
-    finally:
-        active_cache_progress_connections.discard(websocket)
-        try:
-            await websocket.close()
-        except RuntimeError:
-            pass
-
-
-# WebSocket endpoint for real-time log streaming
-@app.websocket("/ws/logs")
-async def websocket_logs_endpoint(websocket: WebSocket):
-    """Stream application logs in real-time via WebSocket."""
-    await websocket.accept()
-
-    handler = get_memory_handler()
-    if not handler:
-        await websocket.close()
-        return
-
-    # Subscribe to log updates
-    log_queue = handler.subscribe()
-
-    try:
-        while True:
-            try:
-                # Wait for new log entry with timeout
-                log_entry = await asyncio.wait_for(log_queue.get(), timeout=30.0)
-                await websocket.send_json({
-                    "type": "log_entry",
-                    "data": log_entry
-                })
-            except asyncio.TimeoutError:
-                # Send heartbeat to keep connection alive
-                await websocket.send_json({"type": "heartbeat"})
-            except RuntimeError as e:
-                if "close message has been sent" in str(e):
-                    break
-                raise
-    except WebSocketDisconnect:
-        pass
-    finally:
-        handler.unsubscribe(log_queue)
-        try:
-            await websocket.close()
-        except RuntimeError:
-            pass
-
-
-# API endpoint to retrieve logs
-@app.get("/api/logs", tags=["logs"])
-async def get_logs(limit: int = 100, level: str = None, offset: int = 0):
-    """
-    Retrieve application logs from memory buffer with pagination.
-
-    Args:
-        limit: Maximum number of log entries to return (default: 100)
-        level: Filter by log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
-        offset: Number of entries to skip from newest (for lazy loading older logs)
-
-    Returns:
-        List of log entries with timestamp, level, logger, and message.
-        Also returns total count and whether there are more logs available.
-    """
-    handler = get_memory_handler()
-    if not handler:
-        return {"logs": [], "count": 0, "total": 0, "has_more": False, "error": "Log handler not initialized"}
-
-    # Clamp limit to reasonable range (no max limit for lazy loading)
-    limit = max(1, limit)
-    offset = max(0, offset)
-
-    logs = handler.get_logs(limit=limit, level=level, offset=offset)
-    total = handler.get_total_count(level=level)
-    has_more = offset + len(logs) < total
-
-    return {"logs": logs, "count": len(logs), "total": total, "has_more": has_more}
-
-
-@app.delete("/api/logs", tags=["logs"])
-async def clear_logs():
-    """Clear all logs from the memory buffer."""
-    handler = get_memory_handler()
-    if handler:
-        handler.clear()
-    return {"status": "ok", "message": "Logs cleared"}
-
-
-# FastAPI routes - Redirect old frontend routes to new React frontend on port 80
-def get_redirect_response(request: Request):
-    """Return redirect page pointing users to the new frontend."""
-    host = request.headers.get("host", "localhost").split(":")[0]  # Remove port if present
-    return templates.TemplateResponse("redirect.html", {"request": request, "host": host})
-
-@app.get("/")
-async def index(request: Request):
-    return get_redirect_response(request)
-
-@app.get("/settings")
-async def settings_page(request: Request):
-    return get_redirect_response(request)
-
-# ============================================================================
-# Unified Settings API
-# ============================================================================
-
-@app.get("/api/settings", tags=["settings"])
-async def get_all_settings():
-    """
-    Get all application settings in a unified structure.
-
-    This endpoint consolidates multiple settings endpoints into a single response.
-    Individual settings endpoints are deprecated but still functional.
-    """
-    return {
-        "app": {
-            "name": state.app_name,
-            "custom_logo": state.custom_logo
-        },
-        "connection": {
-            "preferred_port": state.preferred_port
-        },
-        "patterns": {
-            "clear_pattern_speed": state.clear_pattern_speed,
-            "custom_clear_from_in": state.custom_clear_from_in,
-            "custom_clear_from_out": state.custom_clear_from_out
-        },
-        "auto_play": {
-            "enabled": state.auto_play_enabled,
-            "playlist": state.auto_play_playlist,
-            "run_mode": state.auto_play_run_mode,
-            "pause_time": state.auto_play_pause_time,
-            "clear_pattern": state.auto_play_clear_pattern,
-            "shuffle": state.auto_play_shuffle
-        },
-        "scheduled_pause": {
-            "enabled": state.scheduled_pause_enabled,
-            "control_wled": state.scheduled_pause_control_wled,
-            "finish_pattern": state.scheduled_pause_finish_pattern,
-            "timezone": state.scheduled_pause_timezone,
-            "time_slots": state.scheduled_pause_time_slots
-        },
-        "homing": {
-            "mode": state.homing,
-            "user_override": state.homing_user_override,  # True if user explicitly set, False if auto-detected
-            "angular_offset_degrees": state.angular_homing_offset_degrees,
-            "home_on_connect": state.home_on_connect,
-            "auto_home_enabled": state.auto_home_enabled,
-            "auto_home_after_patterns": state.auto_home_after_patterns,
-            "hard_reset_theta": state.hard_reset_theta  # Enable hard reset when resetting theta
-        },
-        "led": {
-            "provider": state.led_provider,
-            "wled_ip": state.wled_ip,
-            "control_mode": state.dw_led_control_mode,
-            "dw_led": {
-                "num_leds": state.dw_led_num_leds,
-                "gpio_pin": state.dw_led_gpio_pin,
-                "pixel_order": state.dw_led_pixel_order,
-                "brightness": state.dw_led_brightness,
-                "speed": state.dw_led_speed,
-                "intensity": state.dw_led_intensity,
-                "idle_effect": state.dw_led_idle_effect,
-                "playing_effect": state.dw_led_playing_effect,
-                "idle_timeout_enabled": state.dw_led_idle_timeout_enabled,
-                "idle_timeout_minutes": state.dw_led_idle_timeout_minutes
-            }
-        },
-        "mqtt": {
-            "enabled": state.mqtt_enabled,
-            "broker": state.mqtt_broker,
-            "port": state.mqtt_port,
-            "username": state.mqtt_username,
-            "has_password": bool(state.mqtt_password),
-            "client_id": state.mqtt_client_id,
-            "discovery_prefix": state.mqtt_discovery_prefix,
-            "device_id": state.mqtt_device_id,
-            "device_name": state.mqtt_device_name
-        },
-        "machine": {
-            "detected_table_type": state.table_type,
-            "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,
-            "available_table_types": [
-                {"value": "dune_weaver_mini", "label": "Dune Weaver Mini"},
-                {"value": "dune_weaver_mini_pro", "label": "Dune Weaver Mini Pro"},
-                {"value": "dune_weaver_mini_pro_byj", "label": "Dune Weaver Mini Pro (BYJ)"},
-                {"value": "dune_weaver_gold", "label": "Dune Weaver Gold"},
-                {"value": "dune_weaver", "label": "Dune Weaver"},
-                {"value": "dune_weaver_pro", "label": "Dune Weaver Pro"}
-            ]
-        },
-        "security": {
-            "mode": state.security_mode,
-            "has_password": bool(state.security_password_hash)
-        }
-    }
-
-@app.get("/api/manifest.webmanifest", tags=["settings"])
-async def get_dynamic_manifest():
-    """
-    Get a dynamically generated web manifest.
-
-    Returns manifest with custom icons and app name if custom branding is configured,
-    otherwise returns defaults.
-    """
-    # Determine icon paths based on whether custom logo exists
-    if state.custom_logo:
-        icon_base = "/static/custom"
-    else:
-        icon_base = "/static"
-
-    # Use custom app name or default
-    app_name = state.app_name or "Dune Weaver"
-
-    return {
-        "name": app_name,
-        "short_name": app_name,
-        "description": "Control your kinetic sand table",
-        "icons": [
-            {
-                "src": f"{icon_base}/android-chrome-192x192.png",
-                "sizes": "192x192",
-                "type": "image/png",
-                "purpose": "any"
-            },
-            {
-                "src": f"{icon_base}/android-chrome-512x512.png",
-                "sizes": "512x512",
-                "type": "image/png",
-                "purpose": "any"
-            },
-            {
-                "src": f"{icon_base}/android-chrome-192x192.png",
-                "sizes": "192x192",
-                "type": "image/png",
-                "purpose": "maskable"
-            },
-            {
-                "src": f"{icon_base}/android-chrome-512x512.png",
-                "sizes": "512x512",
-                "type": "image/png",
-                "purpose": "maskable"
-            }
-        ],
-        "start_url": "/",
-        "scope": "/",
-        "display": "standalone",
-        "orientation": "any",
-        "theme_color": "#0a0a0a",
-        "background_color": "#0a0a0a",
-        "categories": ["utilities", "entertainment"]
-    }
-
-@app.patch("/api/settings", tags=["settings"])
-async def update_settings(settings_update: SettingsUpdate):
-    """
-    Partially update application settings.
-
-    Only include the categories and fields you want to update.
-    All fields are optional - only provided values will be updated.
-
-    Example: {"app": {"name": "Dune Weaver"}, "auto_play": {"enabled": true}}
-    """
-    updated_categories = []
-    requires_restart = False
-    led_reinit_needed = False
-    old_led_provider = state.led_provider
-
-    # App settings
-    if settings_update.app:
-        if settings_update.app.name is not None:
-            state.app_name = settings_update.app.name or "Dune Weaver"
-        if settings_update.app.custom_logo is not None:
-            state.custom_logo = settings_update.app.custom_logo or None
-        updated_categories.append("app")
-
-    # Connection settings
-    if settings_update.connection:
-        if settings_update.connection.preferred_port is not None:
-            # Store exactly what frontend sends: "__auto__", "__none__", or specific port
-            state.preferred_port = settings_update.connection.preferred_port
-        updated_categories.append("connection")
-
-    # Pattern settings
-    if settings_update.patterns:
-        p = settings_update.patterns
-        if p.clear_pattern_speed is not None:
-            state.clear_pattern_speed = p.clear_pattern_speed if p.clear_pattern_speed > 0 else None
-        if p.custom_clear_from_in is not None:
-            state.custom_clear_from_in = p.custom_clear_from_in or None
-        if p.custom_clear_from_out is not None:
-            state.custom_clear_from_out = p.custom_clear_from_out or None
-        updated_categories.append("patterns")
-
-    # Auto-play settings
-    if settings_update.auto_play:
-        ap = settings_update.auto_play
-        if ap.enabled is not None:
-            state.auto_play_enabled = ap.enabled
-        if ap.playlist is not None:
-            state.auto_play_playlist = ap.playlist or None
-        if ap.run_mode is not None:
-            state.auto_play_run_mode = ap.run_mode
-        if ap.pause_time is not None:
-            state.auto_play_pause_time = ap.pause_time
-        if ap.clear_pattern is not None:
-            state.auto_play_clear_pattern = ap.clear_pattern
-        if ap.shuffle is not None:
-            state.auto_play_shuffle = ap.shuffle
-        updated_categories.append("auto_play")
-
-    # Scheduled pause (Still Sands) settings
-    if settings_update.scheduled_pause:
-        sp = settings_update.scheduled_pause
-        if sp.enabled is not None:
-            state.scheduled_pause_enabled = sp.enabled
-        if sp.control_wled is not None:
-            state.scheduled_pause_control_wled = sp.control_wled
-        if sp.finish_pattern is not None:
-            state.scheduled_pause_finish_pattern = sp.finish_pattern
-        if sp.timezone is not None:
-            # Empty string means use system default (store as None)
-            state.scheduled_pause_timezone = sp.timezone if sp.timezone else None
-            # Clear cached timezone in pattern_manager so it picks up the new setting
-            from modules.core import pattern_manager
-            pattern_manager._cached_timezone = None
-            pattern_manager._cached_zoneinfo = None
-        if sp.time_slots is not None:
-            state.scheduled_pause_time_slots = [slot.model_dump() for slot in sp.time_slots]
-        updated_categories.append("scheduled_pause")
-
-    # Homing settings
-    if settings_update.homing:
-        h = settings_update.homing
-        if h.mode is not None:
-            state.homing = h.mode
-            state.homing_user_override = True  # User explicitly set preference
-        if h.angular_offset_degrees is not None:
-            state.angular_homing_offset_degrees = h.angular_offset_degrees
-        if h.home_on_connect is not None:
-            state.home_on_connect = h.home_on_connect
-        if h.auto_home_enabled is not None:
-            state.auto_home_enabled = h.auto_home_enabled
-        if h.auto_home_after_patterns is not None:
-            state.auto_home_after_patterns = h.auto_home_after_patterns
-        if h.hard_reset_theta is not None:
-            state.hard_reset_theta = h.hard_reset_theta
-        updated_categories.append("homing")
-
-    # LED settings
-    if settings_update.led:
-        led = settings_update.led
-        if led.provider is not None:
-            state.led_provider = led.provider
-            if led.provider != old_led_provider:
-                led_reinit_needed = True
-        if led.wled_ip is not None:
-            state.wled_ip = led.wled_ip or None
-        if led.control_mode is not None:
-            state.dw_led_control_mode = led.control_mode
-        if led.dw_led:
-            dw = led.dw_led
-            if dw.num_leds is not None:
-                state.dw_led_num_leds = dw.num_leds
-            if dw.gpio_pin is not None:
-                state.dw_led_gpio_pin = dw.gpio_pin
-            if dw.pixel_order is not None:
-                state.dw_led_pixel_order = dw.pixel_order
-            if dw.brightness is not None:
-                state.dw_led_brightness = dw.brightness
-            if dw.speed is not None:
-                state.dw_led_speed = dw.speed
-            if dw.intensity is not None:
-                state.dw_led_intensity = dw.intensity
-            if dw.idle_effect is not None:
-                state.dw_led_idle_effect = dw.idle_effect
-            if dw.playing_effect is not None:
-                state.dw_led_playing_effect = dw.playing_effect
-            if dw.idle_timeout_enabled is not None:
-                state.dw_led_idle_timeout_enabled = dw.idle_timeout_enabled
-            if dw.idle_timeout_minutes is not None:
-                state.dw_led_idle_timeout_minutes = dw.idle_timeout_minutes
-        updated_categories.append("led")
-
-    # MQTT settings
-    if settings_update.mqtt:
-        m = settings_update.mqtt
-        if m.enabled is not None:
-            state.mqtt_enabled = m.enabled
-        if m.broker is not None:
-            state.mqtt_broker = m.broker
-        if m.port is not None:
-            state.mqtt_port = m.port
-        if m.username is not None:
-            state.mqtt_username = m.username
-        if m.password is not None:
-            state.mqtt_password = m.password
-        if m.client_id is not None:
-            state.mqtt_client_id = m.client_id
-        if m.discovery_prefix is not None:
-            state.mqtt_discovery_prefix = m.discovery_prefix
-        if m.device_id is not None:
-            state.mqtt_device_id = m.device_id
-        if m.device_name is not None:
-            state.mqtt_device_name = m.device_name
-        updated_categories.append("mqtt")
-        requires_restart = True
-
-    # Machine settings
-    if settings_update.machine:
-        m = settings_update.machine
-        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:
-                from zoneinfo import ZoneInfo
-            except ImportError:
-                from backports.zoneinfo import ZoneInfo
-            try:
-                ZoneInfo(m.timezone)  # Validate
-                state.timezone = m.timezone
-                # Also update scheduled_pause_timezone to keep in sync
-                state.scheduled_pause_timezone = m.timezone
-                # Clear cached timezone in pattern_manager so it picks up the new setting
-                from modules.core import pattern_manager
-                pattern_manager._cached_timezone = None
-                pattern_manager._cached_zoneinfo = None
-                logger.info(f"Timezone updated to: {m.timezone}")
-            except Exception as e:
-                logger.warning(f"Invalid timezone '{m.timezone}': {e}")
-        updated_categories.append("machine")
-
-    # Security settings
-    if settings_update.security:
-        sec = settings_update.security
-        if sec.mode is not None:
-            if sec.mode not in ("off", "lockdown", "play_only"):
-                raise HTTPException(status_code=400, detail="Invalid security mode. Must be 'off', 'lockdown', or 'play_only'.")
-            state.security_mode = sec.mode
-            # When turning off, clear the password hash
-            if sec.mode == "off":
-                state.security_password_hash = ""
-        if sec.password is not None and sec.password != "":
-            state.security_password_hash = hashlib.sha256(sec.password.encode('utf-8')).hexdigest()
-        updated_categories.append("security")
-
-    # Save state
-    state.save()
-
-    # Handle LED reinitialization if provider changed
-    if led_reinit_needed:
-        logger.info(f"LED provider changed from {old_led_provider} to {state.led_provider}, reinitialization may be needed")
-
-    logger.info(f"Settings updated: {', '.join(updated_categories)}")
-
-    return {
-        "success": True,
-        "updated_categories": updated_categories,
-        "requires_restart": requires_restart,
-        "led_reinit_needed": led_reinit_needed
-    }
-
-@app.post("/api/security/verify", tags=["settings"])
-async def verify_security_password(request: SecurityVerifyRequest):
-    """Verify a security password against the stored hash."""
-    if not state.security_password_hash:
-        return {"valid": False}
-    input_hash = hashlib.sha256(request.password.encode('utf-8')).hexdigest()
-    return {"valid": input_hash == state.security_password_hash}
-
-# ============================================================================
-# Multi-Table Identity Endpoints
-# ============================================================================
-
-class TableInfoUpdate(BaseModel):
-    name: Optional[str] = None
-
-class KnownTableAdd(BaseModel):
-    id: str
-    name: str
-    url: str
-    host: Optional[str] = None
-    port: Optional[int] = None
-    version: Optional[str] = None
-
-class KnownTableUpdate(BaseModel):
-    name: Optional[str] = None
-
-@app.get("/api/table-info", tags=["multi-table"])
-async def get_table_info():
-    """
-    Get table identity information for multi-table discovery.
-
-    Returns the table's unique ID, name, and version.
-    """
-    return {
-        "id": state.table_id,
-        "name": state.table_name,
-        "version": await version_manager.get_current_version()
-    }
-
-@app.patch("/api/table-info", tags=["multi-table"])
-async def update_table_info(update: TableInfoUpdate):
-    """
-    Update table identity information.
-
-    Currently only the table name can be updated.
-    The table ID is immutable after generation.
-    """
-    if update.name is not None:
-        state.table_name = update.name.strip() or "Dune Weaver"
-        state.save()
-        logger.info(f"Table name updated to: {state.table_name}")
-        await mdns_discovery.update_name(state.table_name)
-
-    return {
-        "success": True,
-        "id": state.table_id,
-        "name": state.table_name
-    }
-
-@app.get("/api/discovered-tables", tags=["multi-table"])
-async def get_discovered_tables():
-    """
-    Get Dune Weaver tables auto-discovered via mDNS on the local network.
-
-    Unlike known-tables these are not persisted - the list reflects which
-    peer backends are currently advertising themselves. Returns an empty
-    list when mDNS is unavailable (e.g. zeroconf not installed).
-    """
-    return {"tables": mdns_discovery.get_tables()}
-
-@app.get("/api/known-tables", tags=["multi-table"])
-async def get_known_tables():
-    """
-    Get list of known remote tables.
-
-    These are tables that have been manually added and are persisted
-    for multi-table management.
-    """
-    return {"tables": state.known_tables}
-
-@app.post("/api/known-tables", tags=["multi-table"])
-async def add_known_table(table: KnownTableAdd):
-    """
-    Add a known remote table.
-
-    This persists the table information so it's available across
-    browser sessions and devices.
-    """
-    # Check if table with same ID already exists
-    existing_ids = [t.get("id") for t in state.known_tables]
-    if table.id in existing_ids:
-        raise HTTPException(status_code=400, detail="Table with this ID already exists")
-
-    # Check if table with same URL already exists
-    existing_urls = [t.get("url") for t in state.known_tables]
-    if table.url in existing_urls:
-        raise HTTPException(status_code=400, detail="Table with this URL already exists")
-
-    new_table = {
-        "id": table.id,
-        "name": table.name,
-        "url": table.url,
-    }
-    if table.host:
-        new_table["host"] = table.host
-    if table.port:
-        new_table["port"] = table.port
-    if table.version:
-        new_table["version"] = table.version
-
-    state.known_tables.append(new_table)
-    state.save()
-    logger.info(f"Added known table: {table.name} ({table.url})")
-
-    return {"success": True, "table": new_table}
-
-@app.delete("/api/known-tables/{table_id}", tags=["multi-table"])
-async def remove_known_table(table_id: str):
-    """
-    Remove a known remote table by ID.
-    """
-    original_count = len(state.known_tables)
-    state.known_tables = [t for t in state.known_tables if t.get("id") != table_id]
-
-    if len(state.known_tables) == original_count:
-        raise HTTPException(status_code=404, detail="Table not found")
-
-    state.save()
-    logger.info(f"Removed known table: {table_id}")
-
-    return {"success": True}
-
-@app.patch("/api/known-tables/{table_id}", tags=["multi-table"])
-async def update_known_table(table_id: str, update: KnownTableUpdate):
-    """
-    Update a known remote table's name.
-    """
-    for table in state.known_tables:
-        if table.get("id") == table_id:
-            if update.name is not None:
-                table["name"] = update.name.strip()
-            state.save()
-            logger.info(f"Updated known table {table_id}: name={update.name}")
-            return {"success": True, "table": table}
-
-    raise HTTPException(status_code=404, detail="Table not found")
-
-# ============================================================================
-# Individual Settings Endpoints (Deprecated - use /api/settings instead)
-# ============================================================================
-
-@app.get("/api/auto_play-mode", deprecated=True, tags=["settings-deprecated"])
-async def get_auto_play_mode():
-    """DEPRECATED: Use GET /api/settings instead. Get current auto_play mode settings."""
-    return {
-        "enabled": state.auto_play_enabled,
-        "playlist": state.auto_play_playlist,
-        "run_mode": state.auto_play_run_mode,
-        "pause_time": state.auto_play_pause_time,
-        "clear_pattern": state.auto_play_clear_pattern,
-        "shuffle": state.auto_play_shuffle
-    }
-
-@app.post("/api/auto_play-mode", deprecated=True, tags=["settings-deprecated"])
-async def set_auto_play_mode(request: auto_playModeRequest):
-    """DEPRECATED: Use PATCH /api/settings instead. Update auto_play mode settings."""
-    state.auto_play_enabled = request.enabled
-    if request.playlist is not None:
-        state.auto_play_playlist = request.playlist
-    if request.run_mode is not None:
-        state.auto_play_run_mode = request.run_mode
-    if request.pause_time is not None:
-        state.auto_play_pause_time = request.pause_time
-    if request.clear_pattern is not None:
-        state.auto_play_clear_pattern = request.clear_pattern
-    if request.shuffle is not None:
-        state.auto_play_shuffle = request.shuffle
-    state.save()
-    
-    logger.info(f"auto_play mode {'enabled' if request.enabled else 'disabled'}, playlist: {request.playlist}")
-    return {"success": True, "message": "auto_play mode settings updated"}
-
-@app.get("/api/scheduled-pause", deprecated=True, tags=["settings-deprecated"])
-async def get_scheduled_pause():
-    """DEPRECATED: Use GET /api/settings instead. Get current Still Sands settings."""
-    return {
-        "enabled": state.scheduled_pause_enabled,
-        "control_wled": state.scheduled_pause_control_wled,
-        "finish_pattern": state.scheduled_pause_finish_pattern,
-        "timezone": state.scheduled_pause_timezone,
-        "time_slots": state.scheduled_pause_time_slots
-    }
-
-@app.post("/api/scheduled-pause", deprecated=True, tags=["settings-deprecated"])
-async def set_scheduled_pause(request: ScheduledPauseRequest):
-    """Update Still Sands settings."""
-    try:
-        # Validate time slots
-        for i, slot in enumerate(request.time_slots):
-            # Validate time format (HH:MM)
-            try:
-                datetime.strptime(slot.start_time, "%H:%M")
-                datetime.strptime(slot.end_time, "%H:%M")
-            except ValueError:
-                raise HTTPException(
-                    status_code=400,
-                    detail=f"Invalid time format in slot {i+1}. Use HH:MM format."
-                )
-
-            # Validate days setting
-            if slot.days not in ["daily", "weekdays", "weekends", "custom"]:
-                raise HTTPException(
-                    status_code=400,
-                    detail=f"Invalid days setting in slot {i+1}. Must be 'daily', 'weekdays', 'weekends', or 'custom'."
-                )
-
-            # Validate custom days if applicable
-            if slot.days == "custom":
-                if not slot.custom_days or len(slot.custom_days) == 0:
-                    raise HTTPException(
-                        status_code=400,
-                        detail=f"Custom days must be specified for slot {i+1} when days is set to 'custom'."
-                    )
-
-                valid_days = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
-                for day in slot.custom_days:
-                    if day not in valid_days:
-                        raise HTTPException(
-                            status_code=400,
-                            detail=f"Invalid day '{day}' in slot {i+1}. Valid days are: {', '.join(valid_days)}"
-                        )
-
-        # Update state
-        state.scheduled_pause_enabled = request.enabled
-        state.scheduled_pause_control_wled = request.control_wled
-        state.scheduled_pause_finish_pattern = request.finish_pattern
-        state.scheduled_pause_timezone = request.timezone if request.timezone else None
-        state.scheduled_pause_time_slots = [slot.model_dump() for slot in request.time_slots]
-        state.save()
-
-        # Clear cached timezone so it picks up the new setting
-        from modules.core import pattern_manager
-        pattern_manager._cached_timezone = None
-        pattern_manager._cached_zoneinfo = None
-
-        wled_msg = " (with WLED control)" if request.control_wled else ""
-        finish_msg = " (finish pattern first)" if request.finish_pattern else ""
-        tz_msg = f" (timezone: {request.timezone})" if request.timezone else ""
-        logger.info(f"Still Sands {'enabled' if request.enabled else 'disabled'} with {len(request.time_slots)} time slots{wled_msg}{finish_msg}{tz_msg}")
-        return {"success": True, "message": "Still Sands settings updated"}
-
-    except HTTPException:
-        raise
-    except Exception as e:
-        logger.error(f"Error updating Still Sands settings: {str(e)}")
-        raise HTTPException(status_code=500, detail=f"Failed to update Still Sands settings: {str(e)}")
-
-@app.get("/api/homing-config", deprecated=True, tags=["settings-deprecated"])
-async def get_homing_config():
-    """Get homing configuration (mode, compass offset, and auto-home settings)."""
-    return {
-        "homing_mode": state.homing,
-        "angular_homing_offset_degrees": state.angular_homing_offset_degrees,
-        "auto_home_enabled": state.auto_home_enabled,
-        "auto_home_after_patterns": state.auto_home_after_patterns
-    }
-
-class HomingConfigRequest(BaseModel):
-    homing_mode: int = 0  # 0 = crash, 1 = sensor
-    angular_homing_offset_degrees: float = 0.0
-    auto_home_enabled: Optional[bool] = None
-    auto_home_after_patterns: Optional[int] = None
-
-@app.post("/api/homing-config", deprecated=True, tags=["settings-deprecated"])
-async def set_homing_config(request: HomingConfigRequest):
-    """Set homing configuration (mode, compass offset, and auto-home settings)."""
-    try:
-        # Validate homing mode
-        if request.homing_mode not in [0, 1]:
-            raise HTTPException(status_code=400, detail="Homing mode must be 0 (crash) or 1 (sensor)")
-
-        state.homing = request.homing_mode
-        state.homing_user_override = True  # User explicitly set preference
-        state.angular_homing_offset_degrees = request.angular_homing_offset_degrees
-
-        # Update auto-home settings if provided
-        if request.auto_home_enabled is not None:
-            state.auto_home_enabled = request.auto_home_enabled
-        if request.auto_home_after_patterns is not None:
-            if request.auto_home_after_patterns < 1:
-                raise HTTPException(status_code=400, detail="Auto-home after patterns must be at least 1")
-            state.auto_home_after_patterns = request.auto_home_after_patterns
-
-        state.save()
-
-        mode_name = "crash" if request.homing_mode == 0 else "sensor"
-        logger.info(f"Homing mode set to {mode_name}, compass offset set to {request.angular_homing_offset_degrees}°")
-        if request.auto_home_enabled is not None:
-            logger.info(f"Auto-home enabled: {state.auto_home_enabled}, after {state.auto_home_after_patterns} patterns")
-        return {"success": True, "message": "Homing configuration updated"}
-    except HTTPException:
-        raise
-    except Exception as e:
-        logger.error(f"Error updating homing configuration: {str(e)}")
-        raise HTTPException(status_code=500, detail=f"Failed to update homing configuration: {str(e)}")
-
-@app.get("/list_serial_ports")
-async def list_ports():
-    logger.debug("Listing available serial ports")
-    return await asyncio.to_thread(connection_manager.list_serial_ports)
-
-@app.post("/connect")
-async def connect(request: ConnectRequest):
-    # `request.port` carries the board address now (a URL or bare IP). Blank =>
-    # use the configured/default board URL.
-    from modules.connection.fluidnc_client import FluidNCClient
-    try:
-        url = connection_manager._normalize_board_url(request.port or "") or connection_manager.board_url()
-        state.board_url = url
-        state.save()
-        state.conn = FluidNCClient(url)
-        if not state.conn.reachable():
-            state.conn = None
-            raise HTTPException(status_code=500, detail=f"Board not reachable at {url}")
-        if not await asyncio.to_thread(connection_manager.device_init, False):
-            raise HTTPException(status_code=500, detail="Failed to initialize board")
-        logger.info(f"Successfully connected to board at {url}")
-        return {"success": True}
-    except HTTPException:
-        raise
-    except Exception as e:
-        logger.error(f"Failed to connect to board: {str(e)}")
-        raise HTTPException(status_code=500, detail=str(e))
-
-@app.post("/disconnect")
-async def disconnect():
-    try:
-        state.conn.close()
-        logger.info('Successfully disconnected from serial port')
-        return {"success": True}
-    except Exception as e:
-        logger.error(f'Failed to disconnect serial: {str(e)}')
-        raise HTTPException(status_code=500, detail=str(e))
-
-@app.post("/restart_connection")
-async def restart(request: ConnectRequest):
-    if not request.port:
-        logger.warning("Restart serial request received without port")
-        raise HTTPException(status_code=400, detail="No port provided")
-
-    try:
-        logger.info(f"Restarting connection on port {request.port}")
-        connection_manager.restart_connection()
-        return {"success": True}
-    except Exception as e:
-        logger.error(f"Failed to restart serial on port {request.port}: {str(e)}")
-        raise HTTPException(status_code=500, detail=str(e))
-
-
-###############################################################################
-# Debug Serial Terminal - Independent raw serial communication
-###############################################################################
-
-# Store for debug serial connections (separate from main connection)
-_debug_serial_connections: dict = {}
-_debug_serial_lock: Optional[asyncio.Lock] = None
-
-def get_debug_serial_lock() -> asyncio.Lock:
-    """Get or create the debug serial lock in the current event loop."""
-    global _debug_serial_lock
-    if _debug_serial_lock is None:
-        _debug_serial_lock = asyncio.Lock()
-    return _debug_serial_lock
-
-class DebugSerialRequest(BaseModel):
-    port: str
-    baudrate: int = 115200
-    timeout: float = 2.0
-
-class DebugSerialCommand(BaseModel):
-    port: str
-    command: str
-    timeout: float = 2.0
-
-@app.post("/api/debug-serial/open", tags=["debug-serial"])
-async def debug_serial_open(request: DebugSerialRequest):
-    """Open a debug serial connection (independent of main connection)."""
-    import serial
-
-    async with get_debug_serial_lock():
-        # Close existing connection on this port if any
-        if request.port in _debug_serial_connections:
-            try:
-                _debug_serial_connections[request.port].close()
-            except Exception:
-                pass
-            del _debug_serial_connections[request.port]
-
-        try:
-            ser = serial.Serial(
-                request.port,
-                baudrate=request.baudrate,
-                timeout=request.timeout
-            )
-            _debug_serial_connections[request.port] = ser
-            logger.info(f"Debug serial opened on {request.port}")
-            return {"success": True, "port": request.port, "baudrate": request.baudrate}
-        except Exception as e:
-            logger.error(f"Failed to open debug serial on {request.port}: {e}")
-            raise HTTPException(status_code=500, detail=str(e))
-
-@app.post("/api/debug-serial/close", tags=["debug-serial"])
-async def debug_serial_close(request: ConnectRequest):
-    """Close a debug serial connection."""
-    async with get_debug_serial_lock():
-        if request.port not in _debug_serial_connections:
-            return {"success": True, "message": "Port not open"}
-
-        try:
-            _debug_serial_connections[request.port].close()
-            del _debug_serial_connections[request.port]
-            logger.info(f"Debug serial closed on {request.port}")
-            return {"success": True}
-        except Exception as e:
-            logger.error(f"Failed to close debug serial on {request.port}: {e}")
-            raise HTTPException(status_code=500, detail=str(e))
-
-@app.post("/api/debug-serial/send", tags=["debug-serial"])
-async def debug_serial_send(request: DebugSerialCommand):
-    """Send a command and receive response on debug serial connection."""
-
-    async with get_debug_serial_lock():
-        if request.port not in _debug_serial_connections:
-            raise HTTPException(status_code=400, detail="Port not open. Open it first.")
-
-        ser = _debug_serial_connections[request.port]
-
-        try:
-            # Clear input buffer
-            ser.reset_input_buffer()
-
-            # Send command with newline
-            command = request.command.strip()
-            if not command.endswith('\n'):
-                command += '\n'
-
-            await asyncio.to_thread(ser.write, command.encode())
-            await asyncio.to_thread(ser.flush)
-
-            # Read response with timeout - use read() for more reliable data capture
-            responses = []
-            start_time = time.time()
-            buffer = ""
-
-            # Small delay to let response arrive
-            await asyncio.sleep(0.05)
-
-            while time.time() - start_time < request.timeout:
-                try:
-                    # Read all available bytes
-                    waiting = ser.in_waiting
-                    if waiting > 0:
-                        data = await asyncio.to_thread(ser.read, waiting)
-                        if data:
-                            buffer += data.decode('utf-8', errors='replace')
-
-                            # Process complete lines from buffer
-                            while '\n' in buffer:
-                                line, buffer = buffer.split('\n', 1)
-                                line = line.strip()
-                                if line:
-                                    responses.append(line)
-                                    # Check for ok/error to know command completed
-                                    if line.lower() in ['ok', 'error'] or line.lower().startswith('error:'):
-                                        # Give a tiny bit more time for any trailing data
-                                        await asyncio.sleep(0.02)
-                                        # Read any remaining data
-                                        if ser.in_waiting > 0:
-                                            extra = await asyncio.to_thread(ser.read, ser.in_waiting)
-                                            if extra:
-                                                for extra_line in extra.decode('utf-8', errors='replace').strip().split('\n'):
-                                                    if extra_line.strip():
-                                                        responses.append(extra_line.strip())
-                                        break
-                    else:
-                        # No data waiting, small delay
-                        await asyncio.sleep(0.02)
-                except Exception as read_error:
-                    logger.warning(f"Read error: {read_error}")
-                    break
-
-            # Add any remaining buffer content
-            if buffer.strip():
-                responses.append(buffer.strip())
-
-            return {
-                "success": True,
-                "command": request.command.strip(),
-                "responses": responses,
-                "raw": '\n'.join(responses)
-            }
-        except Exception as e:
-            logger.error(f"Debug serial send error: {e}")
-            raise HTTPException(status_code=500, detail=str(e))
-
-@app.get("/api/debug-serial/status", tags=["debug-serial"])
-async def debug_serial_status():
-    """Get status of all debug serial connections."""
-    async with get_debug_serial_lock():
-        status = {}
-        for port, ser in _debug_serial_connections.items():
-            try:
-                status[port] = {
-                    "open": ser.is_open,
-                    "baudrate": ser.baudrate
-                }
-            except Exception:
-                status[port] = {"open": False}
-        return {"connections": status}
-
-
-@app.get("/list_theta_rho_files")
-async def list_theta_rho_files():
-    logger.debug("Listing theta-rho files")
-    # Run the blocking file system operation in a thread pool
-    files = await asyncio.to_thread(pattern_manager.list_theta_rho_files)
-    return sorted(files)
-
-@app.get("/list_theta_rho_files_with_metadata")
-async def list_theta_rho_files_with_metadata():
-    """Get list of theta-rho files with metadata for sorting and filtering.
-    
-    Optimized to process files asynchronously and support request cancellation.
-    """
-    from modules.core.cache_manager import get_pattern_metadata
-    import asyncio
-    from concurrent.futures import ThreadPoolExecutor
-    
-    # Run the blocking file listing in a thread
-    files = await asyncio.to_thread(pattern_manager.list_theta_rho_files)
-    files_with_metadata = []
-
-    # Use ThreadPoolExecutor for I/O-bound operations
-    executor = ThreadPoolExecutor(max_workers=4)
-    
-    def process_file(file_path):
-        """Process a single file and return its metadata."""
-        try:
-            full_path = os.path.join(pattern_manager.THETA_RHO_DIR, file_path)
-            
-            # Get file stats
-            file_stat = os.stat(full_path)
-            
-            # Get cached metadata (this should be fast if cached)
-            metadata = get_pattern_metadata(file_path)
-            
-            # Extract full folder path from file path
-            path_parts = file_path.split('/')
-            if len(path_parts) > 1:
-                # Get everything except the filename (join all folder parts)
-                category = '/'.join(path_parts[:-1])
-            else:
-                category = 'root'
-            
-            # Get file name without extension
-            file_name = os.path.splitext(os.path.basename(file_path))[0]
-            
-            # Use modification time (mtime) for "date modified"
-            date_modified = file_stat.st_mtime
-            
-            return {
-                'path': file_path,
-                'name': file_name,
-                'category': category,
-                'date_modified': date_modified,
-                'coordinates_count': metadata.get('total_coordinates', 0) if metadata else 0
-            }
-            
-        except Exception as e:
-            logger.warning(f"Error getting metadata for {file_path}: {str(e)}")
-            # Include file with minimal info if metadata fails
-            path_parts = file_path.split('/')
-            if len(path_parts) > 1:
-                category = '/'.join(path_parts[:-1])
-            else:
-                category = 'root'
-            return {
-                'path': file_path,
-                'name': os.path.splitext(os.path.basename(file_path))[0],
-                'category': category,
-                'date_modified': 0,
-                'coordinates_count': 0
-            }
-    
-    # Load the entire metadata cache at once (async)
-    # This is much faster than 1000+ individual metadata lookups
-    try:
-        import json
-        metadata_cache_path = "metadata_cache.json"
-        # Use async file reading to avoid blocking the event loop
-        cache_data = await asyncio.to_thread(lambda: json.load(open(metadata_cache_path, 'r')))
-        cache_dict = cache_data.get('data', {})
-        logger.debug(f"Loaded metadata cache with {len(cache_dict)} entries")
-
-        # Process all files using cached data only
-        for file_path in files:
-            try:
-                # Extract category from path
-                path_parts = file_path.split('/')
-                category = '/'.join(path_parts[:-1]) if len(path_parts) > 1 else 'root'
-
-                # Get file name without extension
-                file_name = os.path.splitext(os.path.basename(file_path))[0]
-
-                # Get metadata from cache
-                cached_entry = cache_dict.get(file_path, {})
-                if isinstance(cached_entry, dict) and 'metadata' in cached_entry:
-                    metadata = cached_entry['metadata']
-                    coords_count = metadata.get('total_coordinates', 0)
-                    date_modified = cached_entry.get('mtime', 0)
-                else:
-                    coords_count = 0
-                    date_modified = 0
-
-                files_with_metadata.append({
-                    'path': file_path,
-                    'name': file_name,
-                    'category': category,
-                    'date_modified': date_modified,
-                    'coordinates_count': coords_count
-                })
-
-            except Exception as e:
-                logger.warning(f"Error processing {file_path}: {e}")
-                # Include file with minimal info if processing fails
-                path_parts = file_path.split('/')
-                category = '/'.join(path_parts[:-1]) if len(path_parts) > 1 else 'root'
-                files_with_metadata.append({
-                    'path': file_path,
-                    'name': os.path.splitext(os.path.basename(file_path))[0],
-                    'category': category,
-                    'date_modified': 0,
-                    'coordinates_count': 0
-                })
-
-    except Exception as e:
-        logger.error(f"Failed to load metadata cache, falling back to slow method: {e}")
-        # Fallback to original method if cache loading fails
-        # Create tasks only when needed
-        loop = asyncio.get_running_loop()
-        tasks = [loop.run_in_executor(executor, process_file, file_path) for file_path in files]
-
-        for task in asyncio.as_completed(tasks):
-            try:
-                result = await task
-                files_with_metadata.append(result)
-            except Exception as task_error:
-                logger.error(f"Error processing file: {str(task_error)}")
-
-    # Clean up executor
-    executor.shutdown(wait=False)
-
-    return files_with_metadata
-
-@app.post("/upload_theta_rho")
-async def upload_theta_rho(file: UploadFile = File(...)):
-    """Upload a theta-rho file."""
-    try:
-        # Save the file
-        # Ensure custom_patterns directory exists
-        custom_patterns_dir = os.path.join(pattern_manager.THETA_RHO_DIR, "custom_patterns")
-        os.makedirs(custom_patterns_dir, exist_ok=True)
-        
-        # Use forward slashes for internal path representation to maintain consistency
-        file_path_in_patterns_dir = f"custom_patterns/{file.filename}"
-        full_file_path = os.path.join(pattern_manager.THETA_RHO_DIR, file_path_in_patterns_dir)
-        
-        # Save the uploaded file with proper encoding for Windows compatibility
-        file_content = await file.read()
-        try:
-            # First try to decode as UTF-8 and re-encode to ensure proper encoding
-            text_content = file_content.decode('utf-8')
-            with open(full_file_path, "w", encoding='utf-8') as f:
-                f.write(text_content)
-        except UnicodeDecodeError:
-            # If UTF-8 decoding fails, save as binary (fallback)
-            with open(full_file_path, "wb") as f:
-                f.write(file_content)
-        
-        logger.info(f"File {file.filename} saved successfully")
-        
-        # Generate image preview for the new file with retry logic
-        max_retries = 3
-        for attempt in range(max_retries):
-            try:
-                logger.info(f"Generating preview for {file_path_in_patterns_dir} (attempt {attempt + 1}/{max_retries})")
-                success = await generate_image_preview(file_path_in_patterns_dir)
-                if success:
-                    logger.info(f"Preview generated successfully for {file_path_in_patterns_dir}")
-                    break
-                else:
-                    logger.warning(f"Preview generation failed for {file_path_in_patterns_dir} (attempt {attempt + 1})")
-                    if attempt < max_retries - 1:
-                        await asyncio.sleep(0.5)  # Small delay before retry
-            except Exception as e:
-                logger.error(f"Error generating preview for {file_path_in_patterns_dir} (attempt {attempt + 1}): {str(e)}")
-                if attempt < max_retries - 1:
-                    await asyncio.sleep(0.5)  # Small delay before retry
-        
-        return {"success": True, "message": f"File {file.filename} uploaded successfully"}
-    except Exception as e:
-        logger.error(f"Error uploading file: {str(e)}")
-        raise HTTPException(status_code=500, detail=str(e))
-
-@app.post("/get_theta_rho_coordinates")
-async def get_theta_rho_coordinates(request: GetCoordinatesRequest):
-    """Get theta-rho coordinates for animated preview."""
-    try:
-        # Normalize file path for cross-platform compatibility and remove prefixes
-        file_name = normalize_file_path(request.file_name)
-        file_path = os.path.join(THETA_RHO_DIR, file_name)
-
-        # Check if we can use cached coordinates (already loaded for current playback)
-        # This avoids re-parsing large files (2MB+) which can cause issues on Pi Zero 2W
-        current_file = state.current_playing_file
-        if current_file and state._current_coordinates:
-            # Normalize current file path for comparison
-            current_normalized = normalize_file_path(current_file)
-            if current_normalized == file_name:
-                logger.debug(f"Using cached coordinates for {file_name}")
-                return {
-                    "success": True,
-                    "coordinates": state._current_coordinates,
-                    "total_points": len(state._current_coordinates)
-                }
-
-        # Check file existence asynchronously
-        exists = await asyncio.to_thread(os.path.exists, file_path)
-        if not exists:
-            raise HTTPException(status_code=404, detail=f"File {file_name} not found")
-
-        # Parse the theta-rho file in a thread (not process) to avoid memory pressure
-        # on resource-constrained devices like Pi Zero 2W
-        coordinates = await asyncio.to_thread(parse_theta_rho_file, file_path)
-
-        if not coordinates:
-            raise HTTPException(status_code=400, detail="No valid coordinates found in file")
-
-        return {
-            "success": True,
-            "coordinates": coordinates,
-            "total_points": len(coordinates)
-        }
-
-    except Exception as e:
-        logger.error(f"Error getting coordinates for {request.file_name}: {str(e)}")
-        raise HTTPException(status_code=500, detail=str(e))
-
-@app.post("/run_theta_rho")
-async def run_theta_rho(request: ThetaRhoRequest, background_tasks: BackgroundTasks):
-    if not request.file_name:
-        logger.warning('Run theta-rho request received without file name')
-        raise HTTPException(status_code=400, detail="No file name provided")
-    
-    file_path = None
-    if 'clear' in request.file_name:
-        logger.info(f'Clear pattern file: {request.file_name.split(".")[0]}')
-        file_path = pattern_manager.get_clear_pattern_file(request.file_name.split('.')[0])
-        logger.info(f'Clear pattern file: {file_path}')
-    if not file_path:
-        # Normalize file path for cross-platform compatibility
-        normalized_file_name = normalize_file_path(request.file_name)
-        file_path = os.path.join(pattern_manager.THETA_RHO_DIR, normalized_file_name)
-    if not os.path.exists(file_path):
-        logger.error(f'Theta-rho file not found: {file_path}')
-        raise HTTPException(status_code=404, detail="File not found")
-
-    try:
-        if not (state.conn.is_connected() if state.conn else False):
-            logger.warning("Attempted to run a pattern without a connection")
-            raise HTTPException(status_code=400, detail="Connection not established")
-
-        check_homing_in_progress()
-
-        if pattern_manager.get_pattern_lock().locked():
-            logger.info("Another pattern is running, stopping it first...")
-            await pattern_manager.stop_actions()
-
-        # Clear any stale playlist state before starting a new single pattern.
-        # This prevents a bug where loading state.json after server restart could
-        # cause the old playlist to be used instead of the newly selected pattern.
-        state.current_playlist = None
-        state.current_playlist_index = None
-        state.playlist_mode = None
-
-        files_to_run = [file_path]
-        logger.info(f'Running theta-rho file: {request.file_name} with pre_execution={request.pre_execution}')
-        
-        # Only include clear_pattern if it's not "none"
-        kwargs = {}
-        if request.pre_execution != "none":
-            kwargs['clear_pattern'] = request.pre_execution
-        
-        # Pass arguments properly
-        background_tasks.add_task(
-            pattern_manager.run_theta_rho_files,
-            files_to_run,  # First positional argument
-            **kwargs  # Spread keyword arguments
-        )
-        return {"success": True}
-    except HTTPException as http_exc:
-        logger.error(f'Failed to run theta-rho file {request.file_name}: {http_exc.detail}')
-        raise http_exc
-    except Exception as e:
-        logger.error(f'Failed to run theta-rho file {request.file_name}: {str(e)}')
-        raise HTTPException(status_code=500, detail=str(e))
-
-@app.post("/stop_execution")
-async def stop_execution():
-    if not (state.conn.is_connected() if state.conn else False):
-        logger.warning("Attempted to stop without a connection")
-        raise HTTPException(status_code=400, detail="Connection not established")
-    success = await pattern_manager.stop_actions()
-    if not success:
-        raise HTTPException(status_code=500, detail="Stop timed out - use force_stop")
-    return {"success": True}
-
-@app.post("/force_stop")
-async def force_stop():
-    """Force stop all pattern execution and clear all state. Use when normal stop doesn't work."""
-    logger.info("Force stop requested - clearing all pattern state")
-
-    # Set stop flag first
-    state.stop_requested = True
-    state.pause_requested = False
-
-    # Clear all pattern-related state
-    state.current_playing_file = None
-    state.execution_progress = None
-    state.is_running = False
-    state.is_clearing = False
-    state.is_homing = False
-    state.current_playlist = None
-    state.current_playlist_index = None
-    state.playlist_mode = None
-    state.pause_time_remaining = 0
-
-    # Wake up any waiting tasks
-    try:
-        pattern_manager.get_pause_event().set()
-    except Exception:
-        pass
-
-    # Stop motion controller and clear its queue
-    if pattern_manager.motion_controller.running:
-        pattern_manager.motion_controller.command_queue.put(
-            pattern_manager.MotionCommand('stop')
-        )
-
-    # Force release pattern lock by recreating it
-    pattern_manager.pattern_lock = None  # Will be recreated on next use
-
-    logger.info("Force stop completed - all pattern state cleared")
-    return {"success": True, "message": "Force stop completed"}
-
-@app.post("/soft_reset")
-async def soft_reset():
-    """Send $Bye soft reset to FluidNC controller. Resets position counters to 0."""
-    if not (state.conn and state.conn.is_connected()):
-        logger.warning("Attempted to soft reset without a connection")
-        raise HTTPException(status_code=400, detail="Connection not established")
-
-    try:
-        # Stop any running patterns first
-        await pattern_manager.stop_actions()
-
-        # Use the shared soft reset function
-        await connection_manager.perform_soft_reset()
-
-        return {"success": True, "message": "Soft reset sent. Position reset to 0."}
-    except Exception as e:
-        logger.error(f"Error sending soft reset: {e}")
-        raise HTTPException(status_code=500, detail=str(e))
-
-@app.post("/controller_restart")
-async def controller_restart():
-    """Send $System/Control=RESTART to restart the FluidNC controller."""
-    if not (state.conn and state.conn.is_connected()):
-        logger.warning("Attempted to restart controller without a connection")
-        raise HTTPException(status_code=400, detail="Connection not established")
-
-    try:
-        # Stop any running patterns first
-        await pattern_manager.stop_actions()
-
-        # Send the FluidNC restart command
-        restart_cmd = "$System/Control=RESTART\n"
-        state.conn.send(restart_cmd)
-        logger.info(f"Controller restart command sent to {state.port}")
-
-        # Mark as needing homing since position is now unknown
-        state.is_homed = False
-
-        return {"success": True, "message": "Controller restart command sent. Homing required."}
-    except Exception as e:
-        logger.error(f"Error sending controller restart: {e}")
-        raise HTTPException(status_code=500, detail=str(e))
-
-@app.post("/send_home")
-async def send_home():
-    try:
-        if not (state.conn.is_connected() if state.conn else False):
-            logger.warning("Attempted to move to home without a connection")
-            raise HTTPException(status_code=400, detail="Connection not established")
-
-        if state.is_homing:
-            raise HTTPException(status_code=409, detail="Homing already in progress")
-
-        # Set homing flag to block other movement operations
-        state.is_homing = True
-        logger.info("Homing started - blocking other movement operations")
-
-        try:
-            # Run homing with 15 second timeout
-            success = await asyncio.to_thread(connection_manager.home)
-            if not success:
-                logger.error("Homing failed or timed out")
-                raise HTTPException(status_code=500, detail="Homing failed or timed out after 15 seconds")
-
-            return {"success": True}
-        finally:
-            # Always clear homing flag when done (success or failure)
-            state.is_homing = False
-            logger.info("Homing completed - movement operations unblocked")
-    except HTTPException:
-        raise
-    except Exception as e:
-        logger.error(f"Failed to send home command: {str(e)}")
-        raise HTTPException(status_code=500, detail=str(e))
-
-class SensorHomingRecoveryRequest(BaseModel):
-    switch_to_crash_homing: bool = False
-
-@app.post("/recover_sensor_homing")
-async def recover_sensor_homing(request: SensorHomingRecoveryRequest):
-    """
-    Recover from sensor homing failure.
-
-    If switch_to_crash_homing is True, changes homing mode to crash homing (mode 0)
-    and saves the setting. Then attempts to reconnect and home the device.
-
-    If switch_to_crash_homing is False, just clears the failure flag and retries
-    with sensor homing.
-    """
-    try:
-        # Clear the sensor homing failure flag first
-        state.sensor_homing_failed = False
-
-        if request.switch_to_crash_homing:
-            # Switch to crash homing mode
-            logger.info("Switching to crash homing mode per user request")
-            state.homing = 0
-            state.homing_user_override = True
-            state.save()
-
-        # If already connected, just perform homing
-        if state.conn and state.conn.is_connected():
-            logger.info("Device already connected, performing homing...")
-            state.is_homing = True
-            try:
-                success = await asyncio.to_thread(connection_manager.home)
-                if not success:
-                    # Check if sensor homing failed again
-                    if state.sensor_homing_failed:
-                        return {
-                            "success": False,
-                            "sensor_homing_failed": True,
-                            "message": "Sensor homing failed again. Please check sensor position or switch to crash homing."
-                        }
-                    return {"success": False, "message": "Homing failed"}
-                return {"success": True, "message": "Homing completed successfully"}
-            finally:
-                state.is_homing = False
-        else:
-            # Need to reconnect
-            logger.info("Reconnecting device and performing homing...")
-            state.is_homing = True
-            try:
-                # connect_device includes homing
-                await asyncio.to_thread(connection_manager.connect_device, True)
-
-                # Check if sensor homing failed during connection
-                if state.sensor_homing_failed:
-                    return {
-                        "success": False,
-                        "sensor_homing_failed": True,
-                        "message": "Sensor homing failed. Please check sensor position or switch to crash homing."
-                    }
-
-                if state.conn and state.conn.is_connected():
-                    return {"success": True, "message": "Connected and homed successfully"}
-                else:
-                    return {"success": False, "message": "Failed to establish connection"}
-            finally:
-                state.is_homing = False
-
-    except Exception as e:
-        logger.error(f"Error during sensor homing recovery: {e}")
-        state.is_homing = False
-        raise HTTPException(status_code=500, detail=str(e))
-
-@app.post("/run_theta_rho_file/{file_name}")
-async def run_specific_theta_rho_file(file_name: str):
-    file_path = os.path.join(pattern_manager.THETA_RHO_DIR, file_name)
-    if not os.path.exists(file_path):
-        raise HTTPException(status_code=404, detail="File not found")
-
-    if not (state.conn.is_connected() if state.conn else False):
-        logger.warning("Attempted to run a pattern without a connection")
-        raise HTTPException(status_code=400, detail="Connection not established")
-
-    check_homing_in_progress()
-
-    pattern_manager.run_theta_rho_file(file_path)
-    return {"success": True}
-
-class DeleteFileRequest(BaseModel):
-    file_name: str
-
-@app.post("/delete_theta_rho_file")
-async def delete_theta_rho_file(request: DeleteFileRequest):
-    if not request.file_name:
-        logger.warning("Delete theta-rho file request received without filename")
-        raise HTTPException(status_code=400, detail="No file name provided")
-
-    # Normalize file path for cross-platform compatibility
-    normalized_file_name = normalize_file_path(request.file_name)
-    file_path = os.path.join(pattern_manager.THETA_RHO_DIR, normalized_file_name)
-
-    # Check file existence asynchronously
-    exists = await asyncio.to_thread(os.path.exists, file_path)
-    if not exists:
-        logger.error(f"Attempted to delete non-existent file: {file_path}")
-        raise HTTPException(status_code=404, detail="File not found")
-
-    try:
-        # Delete the pattern file asynchronously
-        await asyncio.to_thread(os.remove, file_path)
-        logger.info(f"Successfully deleted theta-rho file: {request.file_name}")
-        
-        # Clean up cached preview image and metadata asynchronously
-        from modules.core.cache_manager import delete_pattern_cache
-        cache_cleanup_success = await asyncio.to_thread(delete_pattern_cache, normalized_file_name)
-        if cache_cleanup_success:
-            logger.info(f"Successfully cleaned up cache for {request.file_name}")
-        else:
-            logger.warning(f"Cache cleanup failed for {request.file_name}, but pattern was deleted")
-        
-        return {"success": True, "cache_cleanup": cache_cleanup_success}
-    except Exception as e:
-        logger.error(f"Failed to delete theta-rho file {request.file_name}: {str(e)}")
-        raise HTTPException(status_code=500, detail=str(e))
-
-@app.post("/move_to_center")
-async def move_to_center():
-    try:
-        if not (state.conn.is_connected() if state.conn else False):
-            logger.warning("Attempted to move to center without a connection")
-            raise HTTPException(status_code=400, detail="Connection not established")
-
-        check_homing_in_progress()
-
-        # Clear stop_requested to ensure manual move works after pattern stop
-        state.stop_requested = False
-
-        logger.info("Moving device to center position")
-        await pattern_manager.reset_theta()
-        await pattern_manager.move_polar(0, 0)
-
-        # Wait for machine to reach idle before returning
-        idle = await connection_manager.check_idle_async(timeout=60)
-        if not idle:
-            logger.warning("Machine did not reach idle after move to center")
-
-        return {"success": True}
-    except HTTPException:
-        raise
-    except Exception as e:
-        logger.error(f"Failed to move to center: {str(e)}")
-        raise HTTPException(status_code=500, detail=str(e))
-
-@app.post("/move_to_perimeter")
-async def move_to_perimeter():
-    try:
-        if not (state.conn.is_connected() if state.conn else False):
-            logger.warning("Attempted to move to perimeter without a connection")
-            raise HTTPException(status_code=400, detail="Connection not established")
-
-        check_homing_in_progress()
-
-        # Clear stop_requested to ensure manual move works after pattern stop
-        state.stop_requested = False
-
-        logger.info("Moving device to perimeter position")
-        await pattern_manager.reset_theta()
-        await pattern_manager.move_polar(0, 1)
-
-        # Wait for machine to reach idle before returning
-        idle = await connection_manager.check_idle_async(timeout=60)
-        if not idle:
-            logger.warning("Machine did not reach idle after move to perimeter")
-
-        return {"success": True}
-    except HTTPException:
-        raise
-    except Exception as e:
-        logger.error(f"Failed to move to perimeter: {str(e)}")
-        raise HTTPException(status_code=500, detail=str(e))
-
-@app.post("/preview_thr")
-async def preview_thr(request: DeleteFileRequest):
-    if not request.file_name:
-        logger.warning("Preview theta-rho request received without filename")
-        raise HTTPException(status_code=400, detail="No file name provided")
-
-    # Normalize file path for cross-platform compatibility
-    normalized_file_name = normalize_file_path(request.file_name)
-    # Construct the full path to the pattern file to check existence
-    pattern_file_path = os.path.join(pattern_manager.THETA_RHO_DIR, normalized_file_name)
-
-    # Check file existence asynchronously
-    exists = await asyncio.to_thread(os.path.exists, pattern_file_path)
-    if not exists:
-        logger.error(f"Attempted to preview non-existent pattern file: {pattern_file_path}")
-        raise HTTPException(status_code=404, detail="Pattern file not found")
-
-    try:
-        cache_path = get_cache_path(normalized_file_name)
-
-        # Check cache existence asynchronously
-        cache_exists = await asyncio.to_thread(os.path.exists, cache_path)
-        if not cache_exists:
-            logger.info(f"Cache miss for {request.file_name}. Generating preview...")
-            # Attempt to generate the preview if it's missing
-            success = await generate_image_preview(normalized_file_name)
-            cache_exists_after = await asyncio.to_thread(os.path.exists, cache_path)
-            if not success or not cache_exists_after:
-                logger.error(f"Failed to generate or find preview for {request.file_name} after attempting generation.")
-                raise HTTPException(status_code=500, detail="Failed to generate preview image.")
-
-        # Try to get coordinates from metadata cache first
-        metadata = get_pattern_metadata(normalized_file_name)
-        if metadata:
-            first_coord_obj = metadata.get('first_coordinate')
-            last_coord_obj = metadata.get('last_coordinate')
-        else:
-            # Fallback to parsing file if metadata not cached (shouldn't happen after initial cache)
-            logger.debug(f"Metadata cache miss for {request.file_name}, parsing file")
-            coordinates = await asyncio.to_thread(parse_theta_rho_file, pattern_file_path)
-            first_coord = coordinates[0] if coordinates else None
-            last_coord = coordinates[-1] if coordinates else None
-            
-            # Format coordinates as objects with x and y properties
-            first_coord_obj = {"x": first_coord[0], "y": first_coord[1]} if first_coord else None
-            last_coord_obj = {"x": last_coord[0], "y": last_coord[1]} if last_coord else None
-
-        # Return JSON with preview URL and coordinates
-        # URL encode the file_name for the preview URL
-        # Handle both forward slashes and backslashes for cross-platform compatibility
-        encoded_filename = normalized_file_name.replace('\\', '--').replace('/', '--')
-        return {
-            "preview_url": f"/preview/{encoded_filename}",
-            "first_coordinate": first_coord_obj,
-            "last_coordinate": last_coord_obj
-        }
-
-    except HTTPException:
-        raise
-    except Exception as e:
-        logger.error(f"Failed to generate or serve preview for {request.file_name}: {str(e)}")
-        raise HTTPException(status_code=500, detail=f"Failed to serve preview image: {str(e)}")
-
-@app.get("/api/pattern_history/{pattern_name:path}")
-async def get_pattern_history(pattern_name: str):
-    """Get the most recent execution history for a pattern.
-
-    Returns the last completed execution time and speed for the given pattern.
-    """
-    from modules.core.pattern_manager import get_pattern_execution_history
-
-    # Get just the filename if a full path was provided
-    filename = os.path.basename(pattern_name)
-    if not filename.endswith('.thr'):
-        filename = f"{filename}.thr"
-
-    history = get_pattern_execution_history(filename)
-    if history:
-        return history
-    return {"actual_time_seconds": None, "actual_time_formatted": None, "speed": None, "timestamp": None}
-
-@app.get("/api/pattern_history_all")
-async def get_all_pattern_history():
-    """Get execution history for all patterns in a single request.
-
-    Returns a dict mapping pattern names to their most recent execution history.
-    """
-    from modules.core.pattern_manager import EXECUTION_LOG_FILE
-
-    if not os.path.exists(EXECUTION_LOG_FILE):
-        return {}
-
-    try:
-        history_map = {}
-        play_counts = {}
-        with open(EXECUTION_LOG_FILE, 'r') as f:
-            for line in f:
-                line = line.strip()
-                if not line:
-                    continue
-                try:
-                    entry = json.loads(line)
-                    # Only consider fully completed patterns
-                    if entry.get('completed', False):
-                        pattern_name = entry.get('pattern_name')
-                        if pattern_name:
-                            play_counts[pattern_name] = play_counts.get(pattern_name, 0) + 1
-                            # Keep the most recent match (last one in file wins)
-                            history_map[pattern_name] = {
-                                "actual_time_seconds": entry.get('actual_time_seconds'),
-                                "actual_time_formatted": entry.get('actual_time_formatted'),
-                                "speed": entry.get('speed'),
-                                "timestamp": entry.get('timestamp'),
-                                "play_count": play_counts[pattern_name],
-                                "last_played": entry.get('timestamp')
-                            }
-                except json.JSONDecodeError:
-                    continue
-        return history_map
-    except Exception as e:
-        logger.error(f"Failed to read execution time log: {e}")
-        return {}
-
-@app.get("/preview/{encoded_filename}")
-async def serve_preview(encoded_filename: str):
-    """Serve a preview image for a pattern file."""
-    # Decode the filename by replacing -- with the original path separators
-    # First try forward slash (most common case), then backslash if needed
-    file_name = encoded_filename.replace('--', '/')
-    
-    # Apply normalization to handle any remaining path prefixes
-    file_name = normalize_file_path(file_name)
-    
-    # Check if the decoded path exists, if not try backslash decoding
-    cache_path = get_cache_path(file_name)
-    if not os.path.exists(cache_path):
-        # Try with backslash for Windows paths
-        file_name_backslash = encoded_filename.replace('--', '\\')
-        file_name_backslash = normalize_file_path(file_name_backslash)
-        cache_path_backslash = get_cache_path(file_name_backslash)
-        if os.path.exists(cache_path_backslash):
-            file_name = file_name_backslash
-            cache_path = cache_path_backslash
-    # cache_path is already determined above in the decoding logic
-    if not os.path.exists(cache_path):
-        logger.error(f"Preview image not found for {file_name}")
-        raise HTTPException(status_code=404, detail="Preview image not found")
-    
-    # Add caching headers
-    headers = {
-        "Cache-Control": "public, max-age=31536000",  # Cache for 1 year
-        "Content-Type": "image/webp",
-        "Accept-Ranges": "bytes"
-    }
-    
-    return FileResponse(
-        cache_path,
-        media_type="image/webp",
-        headers=headers
-    )
-
-@app.post("/send_coordinate")
-async def send_coordinate(request: CoordinateRequest):
-    if not (state.conn.is_connected() if state.conn else False):
-        logger.warning("Attempted to send coordinate without a connection")
-        raise HTTPException(status_code=400, detail="Connection not established")
-
-    check_homing_in_progress()
-
-    # Clear stop_requested to ensure manual move works after pattern stop
-    state.stop_requested = False
-
-    try:
-        logger.debug(f"Sending coordinate: theta={request.theta}, rho={request.rho}")
-        await pattern_manager.move_polar(request.theta, request.rho)
-
-        # Wait for machine to reach idle before returning
-        idle = await connection_manager.check_idle_async(timeout=60)
-        if not idle:
-            logger.warning("Machine did not reach idle after send_coordinate")
-
-        return {"success": True}
-    except Exception as e:
-        logger.error(f"Failed to send coordinate: {str(e)}")
-        raise HTTPException(status_code=500, detail=str(e))
-
-@app.get("/download/{filename}")
-async def download_file(filename: str):
-    return FileResponse(
-        os.path.join(pattern_manager.THETA_RHO_DIR, filename),
-        filename=filename
-    )
-
-@app.get("/serial_status")
-async def serial_status():
-    connected = state.conn.is_connected() if state.conn else False
-    port = state.port
-    logger.debug(f"Serial status check - connected: {connected}, port: {port}")
-    return {
-        "connected": connected,
-        "port": port,
-        "preferred_port": state.preferred_port
-    }
-
-@app.get("/api/preferred-port", deprecated=True, tags=["settings-deprecated"])
-async def get_preferred_port():
-    """Get the currently configured preferred port for auto-connect."""
-    return {
-        "preferred_port": state.preferred_port
-    }
-
-@app.post("/api/preferred-port", deprecated=True, tags=["settings-deprecated"])
-async def set_preferred_port(request: Request):
-    """Set the preferred port for auto-connect."""
-    data = await request.json()
-    preferred_port = data.get("preferred_port")
-
-    # Allow setting to None to clear the preference
-    if preferred_port == "" or preferred_port == "none":
-        preferred_port = None
-
-    state.preferred_port = preferred_port
-    state.save()
-
-    logger.info(f"Preferred port set to: {preferred_port}")
-    return {
-        "success": True,
-        "preferred_port": state.preferred_port
-    }
-
-@app.post("/pause_execution")
-async def pause_execution():
-    # Check if table is actually idle before trying to pause
-    if await pattern_manager.check_table_is_idle():
-        raise HTTPException(status_code=400, detail="Nothing is currently playing")
-
-    if pattern_manager.pause_execution():
-        return {"success": True, "message": "Execution paused"}
-    raise HTTPException(status_code=500, detail="Failed to pause execution")
-
-@app.post("/resume_execution")
-async def resume_execution():
-    # Check if execution is actually paused before trying to resume
-    if not state.pause_requested:
-        raise HTTPException(status_code=400, detail="Execution is not paused")
-
-    if pattern_manager.resume_execution():
-        return {"success": True, "message": "Execution resumed"}
-    raise HTTPException(status_code=500, detail="Failed to resume execution")
-
-# Playlist endpoints
-@app.get("/list_all_playlists")
-async def list_all_playlists():
-    playlist_names = playlist_manager.list_all_playlists()
-    return playlist_names
-
-@app.get("/get_playlist")
-async def get_playlist(name: str):
-    if not name:
-        raise HTTPException(status_code=400, detail="Missing playlist name parameter")
-
-    playlist = playlist_manager.get_playlist(name)
-    if not playlist:
-        # Auto-create empty playlist if not found
-        logger.info(f"Playlist '{name}' not found, creating empty playlist")
-        playlist_manager.create_playlist(name, [])
-        playlist = {"name": name, "files": []}
-
-    return playlist
-
-@app.post("/create_playlist")
-async def create_playlist(request: PlaylistRequest):
-    success = playlist_manager.create_playlist(request.playlist_name, request.files)
-    return {
-        "success": success,
-        "message": f"Playlist '{request.playlist_name}' created/updated"
-    }
-
-@app.post("/modify_playlist")
-async def modify_playlist(request: PlaylistRequest):
-    success = playlist_manager.modify_playlist(request.playlist_name, request.files)
-    return {
-        "success": success,
-        "message": f"Playlist '{request.playlist_name}' updated"
-    }
-
-@app.delete("/delete_playlist")
-async def delete_playlist(request: DeletePlaylistRequest):
-    success = playlist_manager.delete_playlist(request.playlist_name)
-    if not success:
-        raise HTTPException(
-            status_code=404,
-            detail=f"Playlist '{request.playlist_name}' not found"
-        )
-
-    return {
-        "success": True,
-        "message": f"Playlist '{request.playlist_name}' deleted"
-    }
-
-@app.post("/rename_playlist")
-async def rename_playlist(request: RenamePlaylistRequest):
-    """Rename an existing playlist."""
-    success, message = playlist_manager.rename_playlist(request.old_name, request.new_name)
-    if not success:
-        raise HTTPException(
-            status_code=400,
-            detail=message
-        )
-
-    return {
-        "success": True,
-        "message": message,
-        "new_name": request.new_name
-    }
-
-class AddToPlaylistRequest(BaseModel):
-    playlist_name: str
-    pattern: str
-
-@app.post("/add_to_playlist")
-async def add_to_playlist(request: AddToPlaylistRequest):
-    success = playlist_manager.add_to_playlist(request.playlist_name, request.pattern)
-    if not success:
-        raise HTTPException(status_code=404, detail="Playlist not found")
-    return {"success": True}
-
-@app.post("/run_playlist")
-async def run_playlist_endpoint(request: PlaylistRequest):
-    """Run a playlist with specified parameters."""
-    try:
-        if not (state.conn.is_connected() if state.conn else False):
-            logger.warning("Attempted to run a playlist without a connection")
-            raise HTTPException(status_code=400, detail="Connection not established")
-
-        check_homing_in_progress()
-
-        if not os.path.exists(playlist_manager.PLAYLISTS_FILE):
-            raise HTTPException(status_code=404, detail=f"Playlist '{request.playlist_name}' not found")
-
-        # Start the playlist execution
-        success, message = await playlist_manager.run_playlist(
-            request.playlist_name,
-            pause_time=request.pause_time,
-            clear_pattern=request.clear_pattern,
-            run_mode=request.run_mode,
-            shuffle=request.shuffle
-        )
-        if not success:
-            raise HTTPException(status_code=409, detail=message)
-
-        return {"message": f"Started playlist: {request.playlist_name}"}
-    except Exception as e:
-        logger.error(f"Error running playlist: {e}")
-        raise HTTPException(status_code=500, detail=str(e))
-
-@app.post("/set_speed")
-async def set_speed(request: SpeedRequest):
-    try:
-        if not (state.conn.is_connected() if state.conn else False):
-            logger.warning("Attempted to change speed without a connection")
-            raise HTTPException(status_code=400, detail="Connection not established")
-
-        if request.speed <= 0:
-            logger.warning(f"Invalid speed value received: {request.speed}")
-            raise HTTPException(status_code=400, detail="Invalid speed value")
-
-        state.speed = request.speed
-        # Push the feed live to the board (works mid-pattern; persists across the run).
-        try:
-            await asyncio.to_thread(state.conn.set_feed, int(request.speed))
-        except Exception as e:
-            logger.warning(f"Could not push feed to board: {e}")
-        return {"success": True, "speed": request.speed}
-    except HTTPException:
-        raise  # Re-raise HTTPException as-is
-    except Exception as e:
-        logger.error(f"Failed to set speed: {str(e)}")
-        raise HTTPException(status_code=500, detail=str(e))
-
-@app.get("/check_software_update")
-async def check_updates():
-    update_info = update_manager.check_git_updates()
-    return update_info
-
-@app.post("/update_software")
-async def update_software():
-    logger.info("Starting software update process")
-    success, error_message, error_log = update_manager.update_software()
-    
-    if success:
-        logger.info("Software update completed successfully")
-        return {"success": True}
-    else:
-        logger.error(f"Software update failed: {error_message}\nDetails: {error_log}")
-        raise HTTPException(
-            status_code=500,
-            detail={
-                "error": error_message,
-                "details": error_log
-            }
-        )
-
-@app.post("/set_wled_ip")
-async def set_wled_ip(request: WLEDRequest):
-    """Legacy endpoint for backward compatibility - sets WLED as LED provider"""
-    state.wled_ip = request.wled_ip
-    state.led_provider = "wled" if request.wled_ip else "none"
-    state.led_controller = LEDInterface("wled", request.wled_ip) if request.wled_ip else None
-    if state.led_controller:
-        state.led_controller.effect_idle()
-        _start_idle_led_timeout()
-    state.save()
-    logger.info(f"WLED IP updated: {request.wled_ip}")
-    return {"success": True, "wled_ip": state.wled_ip}
-
-@app.get("/get_wled_ip")
-async def get_wled_ip():
-    """Legacy endpoint for backward compatibility"""
-    if not state.wled_ip:
-        raise HTTPException(status_code=404, detail="No WLED IP set")
-    return {"success": True, "wled_ip": state.wled_ip}
-
-@app.post("/set_led_config", deprecated=True, tags=["settings-deprecated"])
-async def set_led_config(request: LEDConfigRequest):
-    """DEPRECATED: Use PATCH /api/settings instead. Configure LED provider (WLED, DW LEDs, or none)"""
-    if request.provider not in ["wled", "dw_leds", "none"]:
-        raise HTTPException(status_code=400, detail="Invalid provider. Must be 'wled', 'dw_leds', or 'none'")
-
-    state.led_provider = request.provider
-
-    if request.provider == "wled":
-        if not request.ip_address:
-            raise HTTPException(status_code=400, detail="IP address required for WLED")
-        state.wled_ip = request.ip_address
-        state.led_controller = LEDInterface("wled", request.ip_address)
-        logger.info(f"LED provider set to WLED at {request.ip_address}")
-
-    elif request.provider == "dw_leds":
-        # Check if hardware settings changed (requires restart)
-        old_gpio_pin = state.dw_led_gpio_pin
-        old_pixel_order = state.dw_led_pixel_order
-        hardware_changed = (
-            old_gpio_pin != (request.gpio_pin or 18) or
-            old_pixel_order != (request.pixel_order or "RGB")
-        )
-
-        # Stop existing DW LED controller if hardware settings changed
-        if hardware_changed and state.led_controller and state.led_provider == "dw_leds":
-            logger.info("Hardware settings changed, stopping existing LED controller...")
-            controller = state.led_controller.get_controller()
-            if controller and hasattr(controller, 'stop'):
-                try:
-                    controller.stop()
-                    logger.info("LED controller stopped successfully")
-                except Exception as e:
-                    logger.error(f"Error stopping LED controller: {e}")
-            # Clear the reference and give hardware time to release
-            state.led_controller = None
-            await asyncio.sleep(0.5)
-
-        state.dw_led_num_leds = request.num_leds or 60
-        state.dw_led_gpio_pin = request.gpio_pin or 18
-        state.dw_led_pixel_order = request.pixel_order or "RGB"
-        state.dw_led_brightness = request.brightness or 35
-        state.wled_ip = None
-
-        # Create new LED controller with updated settings
-        state.led_controller = LEDInterface(
-            "dw_leds",
-            num_leds=state.dw_led_num_leds,
-            gpio_pin=state.dw_led_gpio_pin,
-            pixel_order=state.dw_led_pixel_order,
-            brightness=state.dw_led_brightness / 100.0,
-            speed=state.dw_led_speed,
-            intensity=state.dw_led_intensity
-        )
-
-        restart_msg = " (restarted)" if hardware_changed else ""
-        logger.info(f"DW LEDs configured{restart_msg}: {state.dw_led_num_leds} LEDs on GPIO{state.dw_led_gpio_pin}, pixel order: {state.dw_led_pixel_order}")
-
-        # Check if initialization succeeded by checking status
-        status = state.led_controller.check_status()
-        if not status.get("connected", False) and status.get("error"):
-            error_msg = status["error"]
-            logger.warning(f"DW LED initialization failed: {error_msg}, but configuration saved for testing")
-            state.led_controller = None
-            # Keep the provider setting for testing purposes
-            # state.led_provider remains "dw_leds" so settings can be saved/tested
-
-            # Save state even with error
-            state.save()
-
-            # Return success with warning instead of error
-            return {
-                "success": True,
-                "warning": error_msg,
-                "hardware_available": False,
-                "provider": state.led_provider,
-                "dw_led_num_leds": state.dw_led_num_leds,
-                "dw_led_gpio_pin": state.dw_led_gpio_pin,
-                "dw_led_pixel_order": state.dw_led_pixel_order,
-                "dw_led_brightness": state.dw_led_brightness
-            }
-
-    else:  # none
-        state.wled_ip = None
-        state.led_controller = None
-        logger.info("LED provider disabled")
-
-    # Show idle effect if controller is configured
-    if state.led_controller:
-        state.led_controller.effect_idle()
-        _start_idle_led_timeout()
-
-    state.save()
-
-    return {
-        "success": True,
-        "provider": state.led_provider,
-        "wled_ip": state.wled_ip,
-        "dw_led_num_leds": state.dw_led_num_leds,
-        "dw_led_gpio_pin": state.dw_led_gpio_pin,
-        "dw_led_brightness": state.dw_led_brightness
-    }
-
-@app.get("/get_led_config", deprecated=True, tags=["settings-deprecated"])
-async def get_led_config():
-    """DEPRECATED: Use GET /api/settings instead. Get current LED provider configuration"""
-    # Auto-detect provider for backward compatibility with existing installations
-    provider = state.led_provider
-    if not provider or provider == "none":
-        # If no provider set but we have IPs configured, auto-detect
-        if state.wled_ip:
-            provider = "wled"
-            state.led_provider = "wled"
-            state.save()
-            logger.info("Auto-detected WLED provider from existing configuration")
-        else:
-            provider = "none"
-
-    return {
-        "success": True,
-        "provider": provider,
-        "wled_ip": state.wled_ip,
-        "dw_led_num_leds": state.dw_led_num_leds,
-        "dw_led_gpio_pin": state.dw_led_gpio_pin,
-        "dw_led_pixel_order": state.dw_led_pixel_order,
-        "dw_led_brightness": state.dw_led_brightness,
-        "dw_led_idle_effect": state.dw_led_idle_effect,
-        "dw_led_playing_effect": state.dw_led_playing_effect
-    }
-
-@app.post("/skip_pattern")
-async def skip_pattern():
-    if not state.current_playlist:
-        raise HTTPException(status_code=400, detail="No playlist is currently running")
-    state.skip_requested = True
-
-    # If the playlist task isn't running (e.g., cancelled by TestClient),
-    # proactively advance state. Otherwise, let the running task handle it
-    # to avoid race conditions with the task's index management.
-    from modules.core import playlist_manager
-    task = playlist_manager._current_playlist_task
-    task_not_running = task is None or task.done()
-
-    if task_not_running and state.current_playlist_index is not None:
-        next_index = state.current_playlist_index + 1
-        if next_index < len(state.current_playlist):
-            state.current_playlist_index = next_index
-            state.current_playing_file = state.current_playlist[next_index]
-
-    return {"success": True}
-
-@app.post("/reorder_playlist")
-async def reorder_playlist(request: dict):
-    """Reorder a pattern in the current playlist queue.
-
-    Since the playlist now contains only main patterns (clear patterns are executed
-    dynamically at runtime), this simply moves the pattern from one position to another.
-    """
-    if not state.current_playlist:
-        raise HTTPException(status_code=400, detail="No playlist is currently running")
-
-    from_index = request.get("from_index")
-    to_index = request.get("to_index")
-
-    if from_index is None or to_index is None:
-        raise HTTPException(status_code=400, detail="from_index and to_index are required")
-
-    playlist = list(state.current_playlist)  # Make a copy to work with
-    current_index = state.current_playlist_index
-
-    # Validate indices
-    if from_index < 0 or from_index >= len(playlist):
-        raise HTTPException(status_code=400, detail="from_index out of range")
-    if to_index < 0 or to_index >= len(playlist):
-        raise HTTPException(status_code=400, detail="to_index out of range")
-
-    # Can't move patterns that have already played (before current_index)
-    # But CAN move the current pattern or swap with it (allows live reordering)
-    if from_index < current_index:
-        raise HTTPException(status_code=400, detail="Cannot move completed pattern")
-    if to_index < current_index:
-        raise HTTPException(status_code=400, detail="Cannot move to completed position")
-
-    # Perform the reorder
-    item = playlist.pop(from_index)
-    # Adjust to_index if moving forward (since we removed an item before it)
-    adjusted_to_index = to_index if to_index < from_index else to_index - 1
-    playlist.insert(adjusted_to_index, item)
-
-    # Update state (this triggers the property setter)
-    state.current_playlist = playlist
-
-    return {"success": True}
-
-@app.post("/add_to_queue")
-async def add_to_queue(request: dict):
-    """Add a pattern to the current playlist queue.
-
-    Args:
-        pattern: The pattern file path to add (e.g., 'circle.thr' or 'subdirectory/pattern.thr')
-        position: 'next' to play after current pattern, 'end' to add to end of queue
-    """
-    if not state.current_playlist:
-        raise HTTPException(status_code=400, detail="No playlist is currently running")
-
-    pattern = request.get("pattern")
-    position = request.get("position", "end")  # 'next' or 'end'
-
-    if not pattern:
-        raise HTTPException(status_code=400, detail="pattern is required")
-
-    # Verify the pattern file exists
-    pattern_path = os.path.join(pattern_manager.THETA_RHO_DIR, pattern)
-    if not os.path.exists(pattern_path):
-        raise HTTPException(status_code=404, detail="Pattern file not found")
-
-    playlist = list(state.current_playlist)
-    current_index = state.current_playlist_index
-
-    if position == "next":
-        # Insert right after the current pattern
-        insert_index = current_index + 1
-    else:
-        # Add to end
-        insert_index = len(playlist)
-
-    playlist.insert(insert_index, pattern)
-    state.current_playlist = playlist
-
-    return {"success": True, "position": insert_index}
-
-@app.get("/api/custom_clear_patterns", deprecated=True, tags=["settings-deprecated"])
-async def get_custom_clear_patterns():
-    """Get the currently configured custom clear patterns."""
-    return {
-        "success": True,
-        "custom_clear_from_in": state.custom_clear_from_in,
-        "custom_clear_from_out": state.custom_clear_from_out
-    }
-
-@app.post("/api/custom_clear_patterns", deprecated=True, tags=["settings-deprecated"])
-async def set_custom_clear_patterns(request: dict):
-    """Set custom clear patterns for clear_from_in and clear_from_out."""
-    try:
-        # Validate that the patterns exist if they're provided
-        if "custom_clear_from_in" in request and request["custom_clear_from_in"]:
-            pattern_path = os.path.join(pattern_manager.THETA_RHO_DIR, request["custom_clear_from_in"])
-            if not os.path.exists(pattern_path):
-                raise HTTPException(status_code=400, detail=f"Pattern file not found: {request['custom_clear_from_in']}")
-            state.custom_clear_from_in = request["custom_clear_from_in"]
-        elif "custom_clear_from_in" in request:
-            state.custom_clear_from_in = None
-            
-        if "custom_clear_from_out" in request and request["custom_clear_from_out"]:
-            pattern_path = os.path.join(pattern_manager.THETA_RHO_DIR, request["custom_clear_from_out"])
-            if not os.path.exists(pattern_path):
-                raise HTTPException(status_code=400, detail=f"Pattern file not found: {request['custom_clear_from_out']}")
-            state.custom_clear_from_out = request["custom_clear_from_out"]
-        elif "custom_clear_from_out" in request:
-            state.custom_clear_from_out = None
-        
-        state.save()
-        logger.info(f"Custom clear patterns updated - in: {state.custom_clear_from_in}, out: {state.custom_clear_from_out}")
-        return {
-            "success": True,
-            "custom_clear_from_in": state.custom_clear_from_in,
-            "custom_clear_from_out": state.custom_clear_from_out
-        }
-    except Exception as e:
-        logger.error(f"Failed to set custom clear patterns: {str(e)}")
-        raise HTTPException(status_code=500, detail=str(e))
-
-@app.get("/api/clear_pattern_speed", deprecated=True, tags=["settings-deprecated"])
-async def get_clear_pattern_speed():
-    """Get the current clearing pattern speed setting."""
-    return {
-        "success": True,
-        "clear_pattern_speed": state.clear_pattern_speed,
-        "effective_speed": state.clear_pattern_speed if state.clear_pattern_speed is not None else state.speed
-    }
-
-@app.post("/api/clear_pattern_speed", deprecated=True, tags=["settings-deprecated"])
-async def set_clear_pattern_speed(request: dict):
-    """DEPRECATED: Use PATCH /api/settings instead. Set the clearing pattern speed."""
-    try:
-        # If speed is None or "none", use default behavior (state.speed)
-        speed_value = request.get("clear_pattern_speed")
-        if speed_value is None or speed_value == "none" or speed_value == "":
-            speed = None
-        else:
-            speed = int(speed_value)
-        
-        # Validate speed range (same as regular speed limits) only if speed is not None
-        if speed is not None and not (50 <= speed <= 2000):
-            raise HTTPException(status_code=400, detail="Speed must be between 50 and 2000")
-        
-        state.clear_pattern_speed = speed
-        state.save()
-        
-        logger.info(f"Clear pattern speed set to {speed if speed is not None else 'default (state.speed)'}")
-        return {
-            "success": True,
-            "clear_pattern_speed": state.clear_pattern_speed,
-            "effective_speed": state.clear_pattern_speed if state.clear_pattern_speed is not None else state.speed
-        }
-    except ValueError:
-        raise HTTPException(status_code=400, detail="Invalid speed value")
-    except Exception as e:
-        logger.error(f"Failed to set clear pattern speed: {str(e)}")
-        raise HTTPException(status_code=500, detail=str(e))
-
-@app.get("/api/app-name", deprecated=True, tags=["settings-deprecated"])
-async def get_app_name():
-    """DEPRECATED: Use GET /api/settings instead. Get current application name."""
-    return {"app_name": state.app_name}
-
-@app.post("/api/app-name", deprecated=True, tags=["settings-deprecated"])
-async def set_app_name(request: dict):
-    """DEPRECATED: Use PATCH /api/settings instead. Update application name."""
-    app_name = request.get("app_name", "").strip()
-    if not app_name:
-        app_name = "Dune Weaver"  # Reset to default if empty
-
-    state.app_name = app_name
-    state.save()
-
-    logger.info(f"Application name updated to: {app_name}")
-    return {"success": True, "app_name": app_name}
-
-# ============================================================================
-# Custom Branding Upload Endpoints
-# ============================================================================
-
-CUSTOM_BRANDING_DIR = os.path.join("static", "custom")
-ALLOWED_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"}
-MAX_LOGO_SIZE = 10 * 1024 * 1024  # 10MB
-MAX_LOGO_DIMENSION = 512  # Max width/height for optimized logo
-
-
-def optimize_logo_image(content: bytes, original_ext: str) -> tuple[bytes, str]:
-    """Optimize logo image by resizing and converting to WebP.
-
-    Args:
-        content: Original image bytes
-        original_ext: Original file extension (e.g., '.png', '.jpg')
-
-    Returns:
-        Tuple of (optimized_bytes, new_extension)
-
-    For SVG files, returns the original content unchanged.
-    For raster images, resizes to MAX_LOGO_DIMENSION and converts to WebP.
-    """
-    # SVG files are already lightweight vectors - keep as-is
-    if original_ext.lower() == ".svg":
-        return content, original_ext
-
-    try:
-        from PIL import Image
-        import io
-
-        with Image.open(io.BytesIO(content)) as img:
-            # Convert to RGBA for transparency support
-            if img.mode in ('P', 'LA') or (img.mode == 'RGBA' and 'transparency' in img.info):
-                img = img.convert('RGBA')
-            elif img.mode != 'RGBA':
-                img = img.convert('RGB')
-
-            # Resize if larger than max dimension (maintain aspect ratio)
-            width, height = img.size
-            if width > MAX_LOGO_DIMENSION or height > MAX_LOGO_DIMENSION:
-                ratio = min(MAX_LOGO_DIMENSION / width, MAX_LOGO_DIMENSION / height)
-                new_size = (int(width * ratio), int(height * ratio))
-                img = img.resize(new_size, Image.Resampling.LANCZOS)
-                logger.info(f"Logo resized from {width}x{height} to {new_size[0]}x{new_size[1]}")
-
-            # Save as WebP with good quality/size balance
-            output = io.BytesIO()
-            img.save(output, format='WEBP', quality=85, method=6)
-            optimized_bytes = output.getvalue()
-
-            original_size = len(content)
-            new_size = len(optimized_bytes)
-            reduction = ((original_size - new_size) / original_size) * 100
-            logger.info(f"Logo optimized: {original_size:,} bytes -> {new_size:,} bytes ({reduction:.1f}% reduction)")
-
-            return optimized_bytes, ".webp"
-
-    except Exception as e:
-        logger.warning(f"Logo optimization failed, using original: {str(e)}")
-        return content, original_ext
-
-def generate_favicon_from_logo(logo_path: str, output_dir: str) -> bool:
-    """Generate circular favicons with transparent background from the uploaded logo.
-
-    Creates:
-    - favicon.ico (multi-size: 256, 128, 64, 48, 32, 16)
-    - favicon-16x16.png, favicon-32x32.png, favicon-96x96.png, favicon-128x128.png
-
-    Returns True on success, False on failure.
-    """
-    try:
-        from PIL import Image, ImageDraw
-
-        def create_circular_transparent(img, size):
-            """Create circular image with transparent background."""
-            resized = img.resize((size, size), Image.Resampling.LANCZOS)
-
-            mask = Image.new('L', (size, size), 0)
-            draw = ImageDraw.Draw(mask)
-            draw.ellipse((0, 0, size - 1, size - 1), fill=255)
-
-            output = Image.new('RGBA', (size, size), (0, 0, 0, 0))
-            output.paste(resized, (0, 0), mask)
-            return output
-
-        with Image.open(logo_path) as img:
-            # Convert to RGBA if needed
-            if img.mode != 'RGBA':
-                img = img.convert('RGBA')
-
-            # Crop to square (center crop)
-            width, height = img.size
-            min_dim = min(width, height)
-            left = (width - min_dim) // 2
-            top = (height - min_dim) // 2
-            img = img.crop((left, top, left + min_dim, top + min_dim))
-
-            # Generate circular favicon PNGs with transparent background
-            png_sizes = {
-                "favicon-16x16.png": 16,
-                "favicon-32x32.png": 32,
-                "favicon-96x96.png": 96,
-                "favicon-128x128.png": 128,
-            }
-            for filename, size in png_sizes.items():
-                icon = create_circular_transparent(img, size)
-                icon.save(os.path.join(output_dir, filename), format='PNG')
-
-            # Generate high-resolution favicon.ico
-            ico_sizes = [256, 128, 64, 48, 32, 16]
-            ico_images = [create_circular_transparent(img, s) for s in ico_sizes]
-            ico_images[0].save(
-                os.path.join(output_dir, "favicon.ico"),
-                format='ICO',
-                append_images=ico_images[1:],
-                sizes=[(s, s) for s in ico_sizes]
-            )
-
-        return True
-    except Exception as e:
-        logger.error(f"Failed to generate favicon: {str(e)}")
-        return False
-
-def generate_pwa_icons_from_logo(logo_path: str, output_dir: str) -> bool:
-    """Generate square PWA app icons from the uploaded logo.
-
-    Creates square icons (no circular crop) - OS will apply its own mask.
-    Composites onto a solid background to avoid transparency issues
-    (iOS fills transparent areas with white on home screen icons).
-
-    Generates:
-    - apple-touch-icon.png (180x180)
-    - android-chrome-192x192.png (192x192)
-    - android-chrome-512x512.png (512x512)
-
-    Returns True on success, False on failure.
-    """
-    try:
-        from PIL import Image
-
-        with Image.open(logo_path) as img:
-            # Convert to RGBA if needed
-            if img.mode != 'RGBA':
-                img = img.convert('RGBA')
-
-            # Crop to square (center crop)
-            width, height = img.size
-            min_dim = min(width, height)
-            left = (width - min_dim) // 2
-            top = (height - min_dim) // 2
-            img = img.crop((left, top, left + min_dim, top + min_dim))
-
-            # Generate square icons at each required size
-            icon_sizes = {
-                "apple-touch-icon.png": 180,
-                "android-chrome-192x192.png": 192,
-                "android-chrome-512x512.png": 512,
-            }
-
-            for filename, size in icon_sizes.items():
-                resized = img.resize((size, size), Image.Resampling.LANCZOS)
-                # Composite onto solid background to eliminate transparency
-                # (iOS shows white behind transparent areas on home screen)
-                background = Image.new('RGB', (size, size), (10, 10, 10))  # #0a0a0a theme color
-                background.paste(resized, (0, 0), resized)  # Use resized as its own alpha mask
-                icon_path = os.path.join(output_dir, filename)
-                background.save(icon_path, format='PNG')
-                logger.info(f"Generated PWA icon: {filename}")
-
-        return True
-    except Exception as e:
-        logger.error(f"Failed to generate PWA icons: {str(e)}")
-        return False
-
-@app.post("/api/upload-logo", tags=["settings"])
-async def upload_logo(file: UploadFile = File(...)):
-    """Upload a custom logo image.
-
-    Supported formats: PNG, JPG, JPEG, GIF, WebP, SVG
-    Maximum upload size: 10MB
-
-    Images are automatically optimized:
-    - Resized to max 512x512 pixels
-    - Converted to WebP format for smaller file size
-    - SVG files are kept as-is (already lightweight)
-
-    A favicon and PWA icons will be automatically generated from the logo.
-    """
-    try:
-        # Validate file extension
-        file_ext = os.path.splitext(file.filename)[1].lower()
-        if file_ext not in ALLOWED_IMAGE_EXTENSIONS:
-            raise HTTPException(
-                status_code=400,
-                detail=f"Invalid file type. Allowed: {', '.join(ALLOWED_IMAGE_EXTENSIONS)}"
-            )
-
-        # Read and validate file size
-        content = await file.read()
-        if len(content) > MAX_LOGO_SIZE:
-            raise HTTPException(
-                status_code=400,
-                detail=f"File too large. Maximum size: {MAX_LOGO_SIZE // (1024*1024)}MB"
-            )
-
-        # Ensure custom branding directory exists
-        os.makedirs(CUSTOM_BRANDING_DIR, exist_ok=True)
-
-        # Delete old logo and favicon if they exist
-        if state.custom_logo:
-            old_logo_path = os.path.join(CUSTOM_BRANDING_DIR, state.custom_logo)
-            if os.path.exists(old_logo_path):
-                os.remove(old_logo_path)
-            # Also remove old favicon
-            old_favicon_path = os.path.join(CUSTOM_BRANDING_DIR, "favicon.ico")
-            if os.path.exists(old_favicon_path):
-                os.remove(old_favicon_path)
-
-        # Optimize the image (resize + convert to WebP for smaller file size)
-        optimized_content, optimized_ext = optimize_logo_image(content, file_ext)
-
-        # Generate a unique filename to prevent caching issues
-        import uuid
-        filename = f"logo-{uuid.uuid4().hex[:8]}{optimized_ext}"
-        file_path = os.path.join(CUSTOM_BRANDING_DIR, filename)
-
-        # Save the optimized logo file
-        with open(file_path, "wb") as f:
-            f.write(optimized_content)
-
-        # Generate favicon and PWA icons from logo (for non-SVG files)
-        favicon_generated = False
-        pwa_icons_generated = False
-        if optimized_ext != ".svg":
-            favicon_generated = generate_favicon_from_logo(file_path, CUSTOM_BRANDING_DIR)
-            pwa_icons_generated = generate_pwa_icons_from_logo(file_path, CUSTOM_BRANDING_DIR)
-
-        # Update state
-        state.custom_logo = filename
-        state.save()
-
-        logger.info(f"Custom logo uploaded: {filename}, favicon generated: {favicon_generated}, PWA icons generated: {pwa_icons_generated}")
-        return {
-            "success": True,
-            "filename": filename,
-            "url": f"/static/custom/{filename}",
-            "favicon_generated": favicon_generated,
-            "pwa_icons_generated": pwa_icons_generated
-        }
-
-    except HTTPException:
-        raise
-    except Exception as e:
-        logger.error(f"Error uploading logo: {str(e)}")
-        raise HTTPException(status_code=500, detail=str(e))
-
-@app.delete("/api/custom-logo", tags=["settings"])
-async def delete_custom_logo():
-    """Remove custom logo, favicon, and PWA icons, reverting to defaults."""
-    try:
-        if state.custom_logo:
-            # Remove logo
-            logo_path = os.path.join(CUSTOM_BRANDING_DIR, state.custom_logo)
-            if os.path.exists(logo_path):
-                os.remove(logo_path)
-
-            # Remove generated favicons
-            favicon_files = [
-                "favicon.ico",
-                "favicon-16x16.png",
-                "favicon-32x32.png",
-                "favicon-96x96.png",
-                "favicon-128x128.png",
-            ]
-            for favicon_name in favicon_files:
-                favicon_path = os.path.join(CUSTOM_BRANDING_DIR, favicon_name)
-                if os.path.exists(favicon_path):
-                    os.remove(favicon_path)
-
-            # Remove generated PWA icons
-            pwa_icons = [
-                "apple-touch-icon.png",
-                "android-chrome-192x192.png",
-                "android-chrome-512x512.png",
-            ]
-            for icon_name in pwa_icons:
-                icon_path = os.path.join(CUSTOM_BRANDING_DIR, icon_name)
-                if os.path.exists(icon_path):
-                    os.remove(icon_path)
-
-            state.custom_logo = None
-            state.save()
-            logger.info("Custom logo, favicon, and PWA icons removed")
-        return {"success": True}
-    except Exception as e:
-        logger.error(f"Error removing logo: {str(e)}")
-        raise HTTPException(status_code=500, detail=str(e))
-
-@app.get("/api/mqtt-config", deprecated=True, tags=["settings-deprecated"])
-async def get_mqtt_config():
-    """DEPRECATED: Use GET /api/settings instead. Get current MQTT configuration.
-
-    Note: Password is not returned for security reasons.
-    """
-    from modules.mqtt import get_mqtt_handler
-    handler = get_mqtt_handler()
-
-    return {
-        "enabled": state.mqtt_enabled,
-        "broker": state.mqtt_broker,
-        "port": state.mqtt_port,
-        "username": state.mqtt_username,
-        # Password is intentionally omitted for security
-        "has_password": bool(state.mqtt_password),
-        "client_id": state.mqtt_client_id,
-        "discovery_prefix": state.mqtt_discovery_prefix,
-        "device_id": state.mqtt_device_id,
-        "device_name": state.mqtt_device_name,
-        "connected": handler.is_connected if hasattr(handler, 'is_connected') else False,
-        "is_mock": handler.__class__.__name__ == 'MockMQTTHandler'
-    }
-
-@app.post("/api/mqtt-config", deprecated=True, tags=["settings-deprecated"])
-async def set_mqtt_config(request: dict):
-    """DEPRECATED: Use PATCH /api/settings instead. Update MQTT configuration. Requires restart to take effect."""
-    try:
-        # Update state with new values
-        state.mqtt_enabled = request.get("enabled", False)
-        state.mqtt_broker = (request.get("broker") or "").strip()
-        state.mqtt_port = int(request.get("port") or 1883)
-        state.mqtt_username = (request.get("username") or "").strip()
-        state.mqtt_password = (request.get("password") or "").strip()
-        state.mqtt_client_id = (request.get("client_id") or "dune_weaver").strip()
-        state.mqtt_discovery_prefix = (request.get("discovery_prefix") or "homeassistant").strip()
-        state.mqtt_device_id = (request.get("device_id") or "dune_weaver").strip()
-        state.mqtt_device_name = (request.get("device_name") or "Dune Weaver").strip()
-
-        # Validate required fields when enabled
-        if state.mqtt_enabled and not state.mqtt_broker:
-            return JSONResponse(
-                content={"success": False, "message": "Broker address is required when MQTT is enabled"},
-                status_code=400
-            )
-
-        state.save()
-        logger.info(f"MQTT configuration updated. Enabled: {state.mqtt_enabled}, Broker: {state.mqtt_broker}")
-
-        return {
-            "success": True,
-            "message": "MQTT configuration saved. Restart the application for changes to take effect.",
-            "requires_restart": True
-        }
-    except ValueError as e:
-        return JSONResponse(
-            content={"success": False, "message": f"Invalid value: {str(e)}"},
-            status_code=400
-        )
-    except Exception as e:
-        logger.error(f"Failed to update MQTT config: {str(e)}")
-        return JSONResponse(
-            content={"success": False, "message": str(e)},
-            status_code=500
-        )
-
-@app.post("/api/mqtt-test")
-async def test_mqtt_connection(request: dict):
-    """Test MQTT connection with provided settings."""
-    import paho.mqtt.client as mqtt_client
-
-    broker = (request.get("broker") or "").strip()
-    port = int(request.get("port") or 1883)
-    username = (request.get("username") or "").strip()
-    password = (request.get("password") or "").strip()
-    client_id = (request.get("client_id") or "dune_weaver_test").strip()
-
-    if not broker:
-        return JSONResponse(
-            content={"success": False, "message": "Broker address is required"},
-            status_code=400
-        )
-
-    try:
-        # Create a test client
-        client = mqtt_client.Client(client_id=client_id + "_test")
-
-        if username:
-            client.username_pw_set(username, password)
-
-        # Connection result
-        connection_result = {"connected": False, "error": None}
-
-        def on_connect(client, userdata, flags, rc):
-            if rc == 0:
-                connection_result["connected"] = True
-            else:
-                error_messages = {
-                    1: "Incorrect protocol version",
-                    2: "Invalid client identifier",
-                    3: "Server unavailable",
-                    4: "Bad username or password",
-                    5: "Not authorized"
-                }
-                connection_result["error"] = error_messages.get(rc, f"Connection failed with code {rc}")
-
-        client.on_connect = on_connect
-
-        # Try to connect with timeout
-        client.connect_async(broker, port, keepalive=10)
-        client.loop_start()
-
-        # Wait for connection result (max 5 seconds)
-        import time
-        start_time = time.time()
-        while time.time() - start_time < 5:
-            if connection_result["connected"] or connection_result["error"]:
-                break
-            await asyncio.sleep(0.1)
-
-        client.loop_stop()
-        client.disconnect()
-
-        if connection_result["connected"]:
-            return {"success": True, "message": "Successfully connected to MQTT broker"}
-        elif connection_result["error"]:
-            return JSONResponse(
-                content={"success": False, "message": connection_result["error"]},
-                status_code=400
-            )
-        else:
-            return JSONResponse(
-                content={"success": False, "message": "Connection timed out. Check broker address and port."},
-                status_code=400
-            )
-
-    except Exception as e:
-        logger.error(f"MQTT test connection failed: {str(e)}")
-        return JSONResponse(
-            content={"success": False, "message": str(e)},
-            status_code=500
-        )
-
-def _read_and_encode_preview(cache_path: str) -> str:
-    """Read preview image from disk and encode as base64.
-    
-    Combines file I/O and base64 encoding in a single function
-    to be run in executor, reducing context switches.
-    """
-    with open(cache_path, 'rb') as f:
-        image_data = f.read()
-    return base64.b64encode(image_data).decode('utf-8')
-
-@app.post("/preview_thr_batch")
-async def preview_thr_batch(request: dict):
-    start = time.time()
-    if not request.get("file_names"):
-        logger.warning("Batch preview request received without filenames")
-        raise HTTPException(status_code=400, detail="No file names provided")
-
-    file_names = request["file_names"]
-    if not isinstance(file_names, list):
-        raise HTTPException(status_code=400, detail="file_names must be a list")
-
-    headers = {
-        "Cache-Control": "public, max-age=3600",  # Cache for 1 hour
-        "Content-Type": "application/json"
-    }
-
-    async def process_single_file(file_name):
-        """Process a single file and return its preview data."""
-        # Check in-memory cache first (for current and next playing patterns)
-        normalized_for_cache = normalize_file_path(file_name)
-        if state._current_preview and state._current_preview[0] == normalized_for_cache:
-            logger.debug(f"Using cached preview for current: {file_name}")
-            return file_name, state._current_preview[1]
-        if state._next_preview and state._next_preview[0] == normalized_for_cache:
-            logger.debug(f"Using cached preview for next: {file_name}")
-            return file_name, state._next_preview[1]
-
-        # Acquire semaphore to limit concurrent processing
-        async with get_preview_semaphore():
-            t1 = time.time()
-            try:
-                # Normalize file path for cross-platform compatibility
-                normalized_file_name = normalize_file_path(file_name)
-                pattern_file_path = os.path.join(pattern_manager.THETA_RHO_DIR, normalized_file_name)
-
-                # Check file existence asynchronously
-                exists = await asyncio.to_thread(os.path.exists, pattern_file_path)
-                if not exists:
-                    logger.warning(f"Pattern file not found: {pattern_file_path}")
-                    return file_name, {"error": "Pattern file not found"}
-
-                cache_path = get_cache_path(normalized_file_name)
-
-                # Check cache existence asynchronously
-                cache_exists = await asyncio.to_thread(os.path.exists, cache_path)
-                if not cache_exists:
-                    logger.info(f"Cache miss for {file_name}. Generating preview...")
-                    success = await generate_image_preview(normalized_file_name)
-                    cache_exists_after = await asyncio.to_thread(os.path.exists, cache_path)
-                    if not success or not cache_exists_after:
-                        logger.error(f"Failed to generate or find preview for {file_name}")
-                        return file_name, {"error": "Failed to generate preview"}
-
-                metadata = get_pattern_metadata(normalized_file_name)
-                if metadata:
-                    first_coord_obj = metadata.get('first_coordinate')
-                    last_coord_obj = metadata.get('last_coordinate')
-                else:
-                    logger.debug(f"Metadata cache miss for {file_name}, parsing file")
-                    # Use thread pool to avoid memory pressure on resource-constrained devices
-                    coordinates = await asyncio.to_thread(parse_theta_rho_file, pattern_file_path)
-                    first_coord = coordinates[0] if coordinates else None
-                    last_coord = coordinates[-1] if coordinates else None
-                    first_coord_obj = {"x": first_coord[0], "y": first_coord[1]} if first_coord else None
-                    last_coord_obj = {"x": last_coord[0], "y": last_coord[1]} if last_coord else None
-
-                # Read image file and encode in executor to avoid blocking event loop
-                loop = asyncio.get_running_loop()
-                image_b64 = await loop.run_in_executor(None, _read_and_encode_preview, cache_path)
-                result = {
-                    "image_data": f"data:image/webp;base64,{image_b64}",
-                    "first_coordinate": first_coord_obj,
-                    "last_coordinate": last_coord_obj
-                }
-
-                # Cache preview for current/next pattern to speed up subsequent requests
-                current_file = state.current_playing_file
-                if current_file:
-                    current_normalized = normalize_file_path(current_file)
-                    if normalized_file_name == current_normalized:
-                        state._current_preview = (normalized_file_name, result)
-                        logger.debug(f"Cached preview for current: {file_name}")
-                    elif state.current_playlist:
-                        # Check if this is the next pattern in playlist
-                        playlist = state.current_playlist
-                        idx = state.current_playlist_index
-                        if idx is not None and idx + 1 < len(playlist):
-                            next_file = normalize_file_path(playlist[idx + 1])
-                            if normalized_file_name == next_file:
-                                state._next_preview = (normalized_file_name, result)
-                                logger.debug(f"Cached preview for next: {file_name}")
-
-                logger.debug(f"Processed {file_name} in {time.time() - t1:.2f}s")
-                return file_name, result
-            except Exception as e:
-                logger.error(f"Error processing {file_name}: {str(e)}")
-                return file_name, {"error": str(e)}
-
-    # Process all files concurrently
-    tasks = [process_single_file(file_name) for file_name in file_names]
-    file_results = await asyncio.gather(*tasks)
-
-    # Convert results to dictionary
-    results = dict(file_results)
-
-    logger.debug(f"Total batch processing time: {time.time() - start:.2f}s for {len(file_names)} files")
-    return JSONResponse(content=results, headers=headers)
-
-@app.get("/playlists")
-async def playlists_page(request: Request):
-    return get_redirect_response(request)
-
-@app.get("/image2sand")
-async def image2sand_page(request: Request):
-    return get_redirect_response(request)
-
-@app.get("/led")
-async def led_control_page(request: Request):
-    return get_redirect_response(request)
-
-# DW LED control endpoints
-@app.get("/api/dw_leds/status")
-async def dw_leds_status():
-    """Get DW LED controller status"""
-    if not state.led_controller or state.led_provider != "dw_leds":
-        return {"connected": False, "message": "DW LEDs not configured"}
-
-    try:
-        return state.led_controller.check_status()
-    except Exception as e:
-        logger.error(f"Failed to check DW LED status: {str(e)}")
-        return {"connected": False, "message": str(e)}
-
-@app.post("/api/dw_leds/power")
-async def dw_leds_power(request: dict):
-    """Control DW LED power (0=off, 1=on, 2=toggle)"""
-    if not state.led_controller or state.led_provider != "dw_leds":
-        raise HTTPException(status_code=400, detail="DW LEDs not configured")
-
-    state_value = request.get("state", 1)
-    if state_value not in [0, 1, 2]:
-        raise HTTPException(status_code=400, detail="State must be 0 (off), 1 (on), or 2 (toggle)")
-
-    try:
-        result = state.led_controller.set_power(state_value)
-
-        # Reset idle timeout when LEDs are manually powered on (only if idle timeout is enabled)
-        # This prevents idle timeout from immediately turning them back off
-        if state_value in [1, 2] and state.dw_led_idle_timeout_enabled:  # Power on or toggle
-            state.dw_led_last_activity_time = time.time()
-            logger.debug("LED activity time reset due to manual power on")
-
-        return result
-    except Exception as e:
-        logger.error(f"Failed to set DW LED power: {str(e)}")
-        raise HTTPException(status_code=500, detail=str(e))
-
-@app.post("/api/dw_leds/brightness")
-async def dw_leds_brightness(request: dict):
-    """Set DW LED brightness (0-100)"""
-    if not state.led_controller or state.led_provider != "dw_leds":
-        raise HTTPException(status_code=400, detail="DW LEDs not configured")
-
-    value = request.get("value", 50)
-    if not 0 <= value <= 100:
-        raise HTTPException(status_code=400, detail="Brightness must be between 0 and 100")
-
-    try:
-        controller = state.led_controller.get_controller()
-        result = controller.set_brightness(value)
-        # Update state if successful
-        if result.get("connected"):
-            state.dw_led_brightness = value
-            state.save()
-        return result
-    except Exception as e:
-        logger.error(f"Failed to set DW LED brightness: {str(e)}")
-        raise HTTPException(status_code=500, detail=str(e))
-
-@app.post("/api/dw_leds/color")
-async def dw_leds_color(request: dict):
-    """Set solid color (manual UI control - always powers on LEDs)"""
-    if not state.led_controller or state.led_provider != "dw_leds":
-        raise HTTPException(status_code=400, detail="DW LEDs not configured")
-
-    # Accept both formats: {"r": 255, "g": 0, "b": 0} or {"color": [255, 0, 0]}
-    if "color" in request:
-        color = request["color"]
-        if not isinstance(color, list) or len(color) != 3:
-            raise HTTPException(status_code=400, detail="Color must be [R, G, B] array")
-        r, g, b = color[0], color[1], color[2]
-    elif "r" in request and "g" in request and "b" in request:
-        r = request["r"]
-        g = request["g"]
-        b = request["b"]
-    else:
-        raise HTTPException(status_code=400, detail="Color must include r, g, b fields or color array")
-
-    try:
-        controller = state.led_controller.get_controller()
-        # Power on LEDs when user manually sets color via UI
-        controller.set_power(1)
-        # Reset idle timeout for manual interaction (only if idle timeout is enabled)
-        if state.dw_led_idle_timeout_enabled:
-            state.dw_led_last_activity_time = time.time()
-        return controller.set_color(r, g, b)
-    except Exception as e:
-        logger.error(f"Failed to set DW LED color: {str(e)}")
-        raise HTTPException(status_code=500, detail=str(e))
-
-@app.post("/api/dw_leds/colors")
-async def dw_leds_colors(request: dict):
-    """Set effect colors (color1, color2, color3) - manual UI control - always powers on LEDs"""
-    if not state.led_controller or state.led_provider != "dw_leds":
-        raise HTTPException(status_code=400, detail="DW LEDs not configured")
-
-    # Parse colors from request
-    color1 = None
-    color2 = None
-    color3 = None
-
-    if "color1" in request:
-        c = request["color1"]
-        if isinstance(c, list) and len(c) == 3:
-            color1 = tuple(c)
-        else:
-            raise HTTPException(status_code=400, detail="color1 must be [R, G, B] array")
-
-    if "color2" in request:
-        c = request["color2"]
-        if isinstance(c, list) and len(c) == 3:
-            color2 = tuple(c)
-        else:
-            raise HTTPException(status_code=400, detail="color2 must be [R, G, B] array")
-
-    if "color3" in request:
-        c = request["color3"]
-        if isinstance(c, list) and len(c) == 3:
-            color3 = tuple(c)
-        else:
-            raise HTTPException(status_code=400, detail="color3 must be [R, G, B] array")
-
-    if not any([color1, color2, color3]):
-        raise HTTPException(status_code=400, detail="Must provide at least one color")
-
-    try:
-        controller = state.led_controller.get_controller()
-        # Power on LEDs when user manually sets colors via UI
-        controller.set_power(1)
-        # Reset idle timeout for manual interaction (only if idle timeout is enabled)
-        if state.dw_led_idle_timeout_enabled:
-            state.dw_led_last_activity_time = time.time()
-        return controller.set_colors(color1=color1, color2=color2, color3=color3)
-    except Exception as e:
-        logger.error(f"Failed to set DW LED colors: {str(e)}")
-        raise HTTPException(status_code=500, detail=str(e))
-
-@app.get("/api/dw_leds/effects")
-async def dw_leds_effects():
-    """Get list of available effects"""
-    if not state.led_controller or state.led_provider != "dw_leds":
-        raise HTTPException(status_code=400, detail="DW LEDs not configured")
-
-    try:
-        controller = state.led_controller.get_controller()
-        effects = controller.get_effects()
-        # Convert tuples to lists for JSON serialization
-        effects_list = [[eid, name] for eid, name in effects]
-        return {
-            "success": True,
-            "effects": effects_list
-        }
-    except Exception as e:
-        logger.error(f"Failed to get DW LED effects: {str(e)}")
-        raise HTTPException(status_code=500, detail=str(e))
-
-@app.get("/api/dw_leds/palettes")
-async def dw_leds_palettes():
-    """Get list of available palettes"""
-    if not state.led_controller or state.led_provider != "dw_leds":
-        raise HTTPException(status_code=400, detail="DW LEDs not configured")
-
-    try:
-        controller = state.led_controller.get_controller()
-        palettes = controller.get_palettes()
-        # Convert tuples to lists for JSON serialization
-        palettes_list = [[pid, name] for pid, name in palettes]
-        return {
-            "success": True,
-            "palettes": palettes_list
-        }
-    except Exception as e:
-        logger.error(f"Failed to get DW LED palettes: {str(e)}")
-        raise HTTPException(status_code=500, detail=str(e))
-
-@app.post("/api/dw_leds/effect")
-async def dw_leds_effect(request: dict):
-    """Set effect by ID (manual UI control - always powers on LEDs)"""
-    if not state.led_controller or state.led_provider != "dw_leds":
-        raise HTTPException(status_code=400, detail="DW LEDs not configured")
-
-    effect_id = request.get("effect_id", 0)
-    speed = request.get("speed")
-    intensity = request.get("intensity")
-
-    try:
-        controller = state.led_controller.get_controller()
-        # Power on LEDs when user manually sets effect via UI
-        controller.set_power(1)
-        # Reset idle timeout for manual interaction (only if idle timeout is enabled)
-        if state.dw_led_idle_timeout_enabled:
-            state.dw_led_last_activity_time = time.time()
-        return controller.set_effect(effect_id, speed=speed, intensity=intensity)
-    except Exception as e:
-        logger.error(f"Failed to set DW LED effect: {str(e)}")
-        raise HTTPException(status_code=500, detail=str(e))
-
-@app.post("/api/dw_leds/palette")
-async def dw_leds_palette(request: dict):
-    """Set palette by ID (manual UI control - always powers on LEDs)"""
-    if not state.led_controller or state.led_provider != "dw_leds":
-        raise HTTPException(status_code=400, detail="DW LEDs not configured")
-
-    palette_id = request.get("palette_id", 0)
-
-    try:
-        controller = state.led_controller.get_controller()
-        # Power on LEDs when user manually sets palette via UI
-        controller.set_power(1)
-        # Reset idle timeout for manual interaction (only if idle timeout is enabled)
-        if state.dw_led_idle_timeout_enabled:
-            state.dw_led_last_activity_time = time.time()
-        return controller.set_palette(palette_id)
-    except Exception as e:
-        logger.error(f"Failed to set DW LED palette: {str(e)}")
-        raise HTTPException(status_code=500, detail=str(e))
-
-@app.post("/api/dw_leds/speed")
-async def dw_leds_speed(request: dict):
-    """Set effect speed (0-255)"""
-    if not state.led_controller or state.led_provider != "dw_leds":
-        raise HTTPException(status_code=400, detail="DW LEDs not configured")
-
-    value = request.get("speed", 128)
-    if not 0 <= value <= 255:
-        raise HTTPException(status_code=400, detail="Speed must be between 0 and 255")
-
-    try:
-        controller = state.led_controller.get_controller()
-        result = controller.set_speed(value)
-        # Save speed to state
-        state.dw_led_speed = value
-        state.save()
-        return result
-    except Exception as e:
-        logger.error(f"Failed to set DW LED speed: {str(e)}")
-        raise HTTPException(status_code=500, detail=str(e))
-
-@app.post("/api/dw_leds/intensity")
-async def dw_leds_intensity(request: dict):
-    """Set effect intensity (0-255)"""
-    if not state.led_controller or state.led_provider != "dw_leds":
-        raise HTTPException(status_code=400, detail="DW LEDs not configured")
-
-    value = request.get("intensity", 128)
-    if not 0 <= value <= 255:
-        raise HTTPException(status_code=400, detail="Intensity must be between 0 and 255")
-
-    try:
-        controller = state.led_controller.get_controller()
-        result = controller.set_intensity(value)
-        # Save intensity to state
-        state.dw_led_intensity = value
-        state.save()
-        return result
-    except Exception as e:
-        logger.error(f"Failed to set DW LED intensity: {str(e)}")
-        raise HTTPException(status_code=500, detail=str(e))
-
-@app.post("/api/dw_leds/save_effect_settings")
-async def dw_leds_save_effect_settings(request: dict):
-    """Save current LED settings as idle or playing effect"""
-    effect_type = request.get("type")  # 'idle' or 'playing'
-
-    settings = {
-        "effect_id": request.get("effect_id"),
-        "palette_id": request.get("palette_id"),
-        "speed": request.get("speed"),
-        "intensity": request.get("intensity"),
-        "color1": request.get("color1"),
-        "color2": request.get("color2"),
-        "color3": request.get("color3")
-    }
-
-    if effect_type == "idle":
-        state.dw_led_idle_effect = settings
-    elif effect_type == "playing":
-        state.dw_led_playing_effect = settings
-    else:
-        raise HTTPException(status_code=400, detail="Invalid effect type. Must be 'idle' or 'playing'")
-
-    state.save()
-    logger.info(f"DW LED {effect_type} effect settings saved: {settings}")
-
-    return {"success": True, "type": effect_type, "settings": settings}
-
-@app.post("/api/dw_leds/clear_effect_settings")
-async def dw_leds_clear_effect_settings(request: dict):
-    """Clear idle or playing effect settings"""
-    effect_type = request.get("type")  # 'idle' or 'playing'
-
-    if effect_type == "idle":
-        state.dw_led_idle_effect = None
-    elif effect_type == "playing":
-        state.dw_led_playing_effect = None
-    else:
-        raise HTTPException(status_code=400, detail="Invalid effect type. Must be 'idle' or 'playing'")
-
-    state.save()
-    logger.info(f"DW LED {effect_type} effect settings cleared")
-
-    return {"success": True, "type": effect_type}
-
-@app.get("/api/dw_leds/get_effect_settings")
-async def dw_leds_get_effect_settings():
-    """Get saved idle and playing effect settings"""
-    return {
-        "idle_effect": state.dw_led_idle_effect,
-        "playing_effect": state.dw_led_playing_effect
-    }
-
-@app.post("/api/dw_leds/idle_timeout")
-async def dw_leds_set_idle_timeout(request: dict):
-    """Configure LED idle timeout settings"""
-    enabled = request.get("enabled", False)
-    minutes = request.get("minutes", 30)
-
-    # Validate minutes (between 1 and 1440 - 24 hours)
-    if minutes < 1 or minutes > 1440:
-        raise HTTPException(status_code=400, detail="Timeout must be between 1 and 1440 minutes")
-
-    state.dw_led_idle_timeout_enabled = enabled
-    state.dw_led_idle_timeout_minutes = minutes
-
-    # Reset activity time when settings change
-    import time
-    state.dw_led_last_activity_time = time.time()
-
-    state.save()
-    logger.info(f"DW LED idle timeout configured: enabled={enabled}, minutes={minutes}")
-
-    return {
-        "success": True,
-        "enabled": enabled,
-        "minutes": minutes
-    }
-
-@app.get("/api/dw_leds/idle_timeout")
-async def dw_leds_get_idle_timeout():
-    """Get LED idle timeout settings"""
-    import time
-
-    # Calculate remaining time if timeout is active
-    remaining_minutes = None
-    if state.dw_led_idle_timeout_enabled and state.dw_led_last_activity_time:
-        elapsed_seconds = time.time() - state.dw_led_last_activity_time
-        timeout_seconds = state.dw_led_idle_timeout_minutes * 60
-        remaining_seconds = max(0, timeout_seconds - elapsed_seconds)
-        remaining_minutes = round(remaining_seconds / 60, 1)
-
-    return {
-        "enabled": state.dw_led_idle_timeout_enabled,
-        "minutes": state.dw_led_idle_timeout_minutes,
-        "remaining_minutes": remaining_minutes
-    }
-
-# ── Screen (LCD backlight) control endpoints ──────────────────────
-
-@app.get("/api/screen/status")
-async def screen_status():
-    """Get screen controller status."""
-    if not state.screen_controller:
-        return {"available": False, "message": "Screen controller not initialized"}
-    return state.screen_controller.get_status()
-
-@app.post("/api/screen/power")
-async def screen_power(request: dict):
-    """Turn screen on/off. Body: {"on": true/false}"""
-    if not state.screen_controller or not state.screen_controller.available:
-        raise HTTPException(status_code=400, detail="Screen control not available")
-
-    on = request.get("on", True)
-    result = state.screen_controller.set_power(on)
-    if not result.get("success"):
-        raise HTTPException(status_code=500, detail=result.get("message", "Unknown error"))
-
-    # Publish updated state to MQTT
-    if state.mqtt_handler and state.mqtt_handler.is_enabled:
-        state.mqtt_handler._publish_screen_state()
-
-    return result
-
-@app.post("/api/screen/brightness")
-async def screen_brightness(request: dict):
-    """Set screen brightness. Body: {"value": 0-max_brightness}"""
-    if not state.screen_controller or not state.screen_controller.available:
-        raise HTTPException(status_code=400, detail="Screen control not available")
-
-    value = request.get("value", 128)
-    result = state.screen_controller.set_brightness(value)
-    if not result.get("success"):
-        raise HTTPException(status_code=500, detail=result.get("message", "Unknown error"))
-
-    # Publish updated state to MQTT
-    if state.mqtt_handler and state.mqtt_handler.is_enabled:
-        state.mqtt_handler._publish_screen_state()
-
-    return result
-
-@app.get("/table_control")
-async def table_control_page(request: Request):
-    return get_redirect_response(request)
-
-@app.get("/cache-progress")
-async def get_cache_progress_endpoint():
-    """Get the current cache generation progress."""
-    from modules.core.cache_manager import get_cache_progress
-    return get_cache_progress()
-
-@app.post("/rebuild_cache")
-async def rebuild_cache_endpoint():
-    """Trigger a rebuild of the pattern cache."""
-    try:
-        from modules.core.cache_manager import rebuild_cache
-        await rebuild_cache()
-        return {"success": True, "message": "Cache rebuild completed successfully"}
-    except Exception as e:
-        logger.error(f"Failed to rebuild cache: {str(e)}")
-        raise HTTPException(status_code=500, detail=str(e))
-
-def signal_handler(signum, frame):
-    """Handle shutdown signals gracefully."""
-    logger.info("Received shutdown signal, cleaning up...")
-    try:
-        # Turn off all LEDs on shutdown
-        if state.led_controller:
-            state.led_controller.set_power(0)
-
-        # Stop pattern manager motion controller
-        pattern_manager.motion_controller.stop()
-
-        # Set stop flags to halt any running patterns
-        state.stop_requested = True
-        state.pause_requested = False
-
-        state.save()
-        logger.info("Cleanup completed")
-    except Exception as e:
-        logger.error(f"Error during cleanup: {str(e)}")
-    finally:
-        logger.info("Exiting application...")
-        # Use os._exit after cleanup is complete to avoid async stack tracebacks
-        # This is safe because we've already: shut down process pool, stopped motion controller, saved state
-        os._exit(0)
-
-@app.get("/api/version")
-async def get_version_info(force_refresh: bool = False):
-    """Get current and latest version information
-
-    Args:
-        force_refresh: If true, bypass cache and fetch fresh data from GitHub
-    """
-    try:
-        version_info = await version_manager.get_version_info(force_refresh=force_refresh)
-        return JSONResponse(content=version_info)
-    except Exception as e:
-        logger.error(f"Error getting version info: {e}")
-        return JSONResponse(
-            content={
-                "current": await version_manager.get_current_version(),
-                "latest": await version_manager.get_current_version(),
-                "update_available": False,
-                "error": "Unable to check for updates"
-            },
-            status_code=200
-        )
-
-@app.post("/api/update")
-async def trigger_update():
-    """Trigger software update by running `dw update` as a detached process.
-
-    The `dw` CLI handles pulling code and restarting the service.
-    We fire-and-forget so the response returns immediately before the
-    service goes down for restart.
-    """
-    try:
-        logger.info("Update triggered via API")
-        dw_path = '/usr/local/bin/dw'
-        log_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'update.log')
-        logger.info(f"Running: {dw_path} update (log: {log_file})")
-        with open(log_file, 'w') as f:
-            subprocess.Popen(
-                [dw_path, 'update'],
-                stdout=f,
-                stderr=subprocess.STDOUT,
-                start_new_session=True,
-                cwd=os.path.dirname(os.path.abspath(__file__))
-            )
-        return JSONResponse(content={
-            "success": True,
-            "message": "Update started"
-        })
-    except Exception as e:
-        logger.error(f"Error triggering update: {e}")
-        return JSONResponse(
-            content={"success": False, "message": f"Failed to trigger update: {str(e)}"},
-            status_code=500
-        )
-
-@app.post("/api/system/shutdown")
-async def shutdown_system():
-    """Shutdown the system"""
-    try:
-        logger.warning("Shutdown initiated via API")
-
-        # Schedule shutdown command after a short delay to allow response to be sent
-        def delayed_shutdown():
-            time.sleep(2)  # Give time for response to be sent
-            try:
-                subprocess.run(["/usr/bin/sudo", "/usr/bin/systemctl", "poweroff"], check=True)
-                logger.info("System shutdown command executed successfully")
-            except FileNotFoundError:
-                logger.error("sudo or systemctl command not found - ensure systemd is available")
-            except Exception as e:
-                logger.error(f"Error executing host shutdown command: {e}")
-
-        import threading
-        shutdown_thread = threading.Thread(target=delayed_shutdown)
-        shutdown_thread.start()
-
-        return {"success": True, "message": "System shutdown initiated"}
-    except Exception as e:
-        logger.error(f"Error initiating shutdown: {e}")
-        return JSONResponse(
-            content={"success": False, "message": str(e)},
-            status_code=500
-        )
-
-@app.post("/api/system/restart")
-async def restart_system():
-    """Restart the Dune Weaver service via systemctl."""
-    try:
-        logger.warning("Restart initiated via API")
-
-        # Schedule restart command after a short delay to allow response to be sent
-        def delayed_restart():
-            time.sleep(2)  # Give time for response to be sent
-            try:
-                subprocess.run(["/usr/bin/sudo", "/usr/bin/systemctl", "restart", "dune-weaver"], check=True)
-                logger.info("Service restart command executed successfully")
-            except FileNotFoundError:
-                logger.error("sudo or systemctl command not found - ensure systemd is available")
-            except Exception as e:
-                logger.error(f"Error executing service restart: {e}")
-
-        import threading
-        restart_thread = threading.Thread(target=delayed_restart)
-        restart_thread.start()
-
-        return {"success": True, "message": "System restart initiated"}
-    except Exception as e:
-        logger.error(f"Error initiating restart: {e}")
-        return JSONResponse(
-            content={"success": False, "message": str(e)},
-            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...")
-    uvicorn.run(app, host="0.0.0.0", port=8080, workers=1)  # Set workers to 1 to avoid multiple signal handlers
-
-if __name__ == "__main__":
+from fastapi import FastAPI, UploadFile, File, HTTPException, BackgroundTasks, WebSocket, WebSocketDisconnect, Request
+from fastapi.responses import JSONResponse, FileResponse
+from fastapi.staticfiles import StaticFiles
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.templating import Jinja2Templates
+from pydantic import BaseModel
+from typing import List, Optional
+import os
+import logging
+from datetime import datetime
+from modules.connection import connection_manager
+from modules.core import pattern_manager
+from modules.core.pattern_manager import parse_theta_rho_file, THETA_RHO_DIR
+from modules.core import playlist_manager
+from modules.core import board_settings
+from modules.core import execution
+from modules.update import update_manager
+from modules.core.state import state
+from modules import mqtt
+import signal
+import asyncio
+from contextlib import asynccontextmanager
+from modules.led.led_interface import LEDInterface
+from modules.screen.screen_controller import ScreenController
+from modules.led.idle_timeout_manager import idle_timeout_manager
+from modules.core.cache_manager import get_cache_path, generate_image_preview, get_pattern_metadata
+from modules.core.version_manager import version_manager
+from modules.core.mdns_discovery import discovery as mdns_discovery
+from modules.core.log_handler import init_memory_handler, get_memory_handler
+from modules.wifi.router import router as wifi_router, captive_portal_router
+import json
+import base64
+import hashlib
+import time
+import subprocess
+
+# Get log level from environment variable, default to INFO
+log_level_str = os.getenv('LOG_LEVEL', 'INFO').upper()
+log_level = getattr(logging, log_level_str, logging.INFO)
+
+logging.basicConfig(
+    level=log_level,
+    format='%(asctime)s - %(name)s:%(lineno)d - %(levelname)s - %(message)s',
+    handlers=[
+        logging.StreamHandler(),
+    ]
+)
+
+# Initialize memory log handler for web UI log viewer
+# Increased to 5000 entries to support lazy loading in the UI
+init_memory_handler(max_entries=5000)
+
+logger = logging.getLogger(__name__)
+
+
+async def _check_table_is_idle() -> bool:
+    """Helper function to check if table is idle."""
+    return not state.current_playing_file or state.pause_requested
+
+
+def _start_idle_led_timeout():
+    """Start idle LED timeout if enabled."""
+    if not state.dw_led_idle_timeout_enabled or state.dw_led_idle_timeout_minutes <= 0:
+        return
+
+    logger.debug(f"Starting idle LED timeout: {state.dw_led_idle_timeout_minutes} minutes")
+    idle_timeout_manager.start_idle_timeout(
+        timeout_minutes=state.dw_led_idle_timeout_minutes,
+        state=state,
+        check_idle_callback=_check_table_is_idle
+    )
+
+
+def check_homing_in_progress():
+    """Check if homing is in progress and raise exception if so."""
+    if state.is_homing:
+        raise HTTPException(status_code=409, detail="Cannot perform this action while homing is in progress")
+
+
+def normalize_file_path(file_path: str) -> str:
+    """Normalize file path separators for consistent cross-platform handling."""
+    if not file_path:
+        return ''
+    
+    # First normalize path separators
+    normalized = file_path.replace('\\', '/')
+    
+    # Remove only the patterns directory prefix from the beginning, not patterns within the path
+    if normalized.startswith('./patterns/'):
+        normalized = normalized[11:]
+    elif normalized.startswith('patterns/'):
+        normalized = normalized[9:]
+    
+    return normalized
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+    # Startup
+    logger.info("Starting Dune Weaver application...")
+
+    # Register signal handlers
+    signal.signal(signal.SIGINT, signal_handler)
+    signal.signal(signal.SIGTERM, signal_handler)
+
+    # Connect device in background so the web server starts immediately
+    async def connect_and_home():
+        """Connect to device and perform homing in background."""
+        try:
+            # Connect without homing first (fast)
+            await asyncio.to_thread(connection_manager.connect_device, False)
+
+            # If connected, perform homing in background (unless disabled)
+            if state.conn and state.conn.is_connected():
+                if not state.home_on_connect:
+                    logger.info("Device connected. Home-on-connect is disabled — skipping automatic homing.")
+                else:
+                    logger.info("Device connected, starting homing in background...")
+                    state.is_homing = True
+                    try:
+                        success = await asyncio.to_thread(connection_manager.home)
+                        if not success:
+                            logger.warning("Background homing failed or was skipped")
+                            # If sensor homing failed, close connection and wait for user action
+                            if state.sensor_homing_failed:
+                                logger.error("Sensor homing failed - closing connection. User must check sensor or switch to crash homing.")
+                                if state.conn:
+                                    await asyncio.to_thread(state.conn.close)
+                                    state.conn = None
+                                return  # Don't proceed with auto-play
+                    finally:
+                        state.is_homing = False
+                        logger.info("Background homing completed")
+
+                # Auto-play on boot lives on the board ($Playlist/Autostart,
+                # set via Settings) — nothing to do host-side.
+        except Exception as e:
+            logger.warning(f"Failed to auto-connect to board: {str(e)}")
+
+    # Start connection/homing in background - doesn't block server startup
+    asyncio.create_task(connect_and_home())
+
+    # The board observer is the single status loop: it polls /sand_status,
+    # translates it into the /ws/status contract, logs play history on file
+    # transitions, runs the clear-speed and WLED quiet-hours shims, adopts
+    # board-side Still Sands edits, and broadcasts to all clients.
+    execution.observer.on_status = broadcast_status_update
+    execution.observer.start()
+
+    # Initialize LED controller based on saved configuration
+    try:
+        # Auto-detect provider for backward compatibility with existing installations
+        if not state.led_provider or state.led_provider == "none":
+            if state.wled_ip:
+                state.led_provider = "wled"
+                logger.info("Auto-detected WLED provider from existing configuration")
+
+        # Initialize the appropriate controller
+        if state.led_provider == "wled" and state.wled_ip:
+            state.led_controller = LEDInterface("wled", state.wled_ip)
+            logger.info(f"LED controller initialized: WLED at {state.wled_ip}")
+        elif state.led_provider == "board":
+            state.led_controller = LEDInterface("board")
+            logger.info("LED controller initialized: table's built-in LEDs (firmware-controlled)")
+        else:
+            if state.led_provider == "dw_leds":
+                # Host GPIO NeoPixels were removed — the table's own ring
+                # (firmware-controlled) replaced them.
+                logger.warning("LED provider 'dw_leds' no longer exists; "
+                               "select 'Table LEDs' in Settings")
+            state.led_controller = None
+            logger.info("LED controller not configured")
+
+        # Save if provider was auto-detected
+        if state.led_provider and state.wled_ip:
+            state.save()
+    except Exception as e:
+        logger.warning(f"Failed to initialize LED controller: {str(e)}")
+        state.led_controller = None
+
+    # Initialize screen controller for LCD backlight control
+    try:
+        state.screen_controller = ScreenController()
+        if state.screen_controller.available:
+            logger.info("Screen controller initialized (backlight control available)")
+        else:
+            logger.info("Screen controller initialized (no backlight device found)")
+    except Exception as e:
+        logger.warning(f"Failed to initialize screen controller: {e}")
+        state.screen_controller = None
+
+    # Note: auto_play is now handled in connect_and_home() after homing completes
+
+    try:
+        mqtt.init_mqtt()
+    except Exception as e:
+        logger.warning(f"Failed to initialize MQTT: {str(e)}")
+    
+    # Schedule cache generation check for later (non-blocking startup)
+    async def delayed_cache_check():
+        """Check and generate cache in background."""
+        try:
+            logger.info("Starting cache check...")
+
+            from modules.core.cache_manager import is_cache_generation_needed_async, generate_cache_background
+
+            if await is_cache_generation_needed_async():
+                logger.info("Cache generation needed, starting background task...")
+                asyncio.create_task(generate_cache_background())  # Don't await - run in background
+            else:
+                logger.info("Cache is up to date, skipping generation")
+        except Exception as e:
+            logger.warning(f"Failed during cache generation: {str(e)}")
+
+    # Start cache check in background immediately
+    asyncio.create_task(delayed_cache_check())
+
+
+    # Advertise this table via mDNS and browse for peer tables (best-effort)
+    try:
+        await mdns_discovery.start(
+            table_id=state.table_id,
+            table_name=state.table_name,
+            port=8080,
+            version=await version_manager.get_current_version(),
+        )
+    except Exception as e:
+        logger.warning(f"mDNS table discovery unavailable: {e}")
+
+    yield  # This separates startup from shutdown code
+
+    # Shutdown
+    logger.info("Shutting down Dune Weaver application...")
+    await mdns_discovery.stop()
+
+app = FastAPI(lifespan=lifespan)
+
+# Add CORS middleware to allow cross-origin requests from other Dune Weaver frontends
+# This enables multi-table control from a single frontend
+# Note: allow_credentials must be False when allow_origins=["*"] (browser security requirement)
+app.add_middleware(
+    CORSMiddleware,
+    allow_origins=["*"],  # Allow all origins for local network access
+    allow_credentials=False,
+    allow_methods=["*"],
+    allow_headers=["*"],
+)
+
+templates = Jinja2Templates(directory="templates")
+app.mount("/static", StaticFiles(directory="static"), name="static")
+
+# Include WiFi management router
+app.include_router(wifi_router)
+app.include_router(captive_portal_router)
+
+# Global semaphore to limit concurrent preview processing
+# Prevents resource exhaustion when loading many previews simultaneously
+# Lazily initialized to avoid "attached to a different loop" errors
+_preview_semaphore: Optional[asyncio.Semaphore] = None
+
+def get_preview_semaphore() -> asyncio.Semaphore:
+    """Get or create the preview semaphore in the current event loop."""
+    global _preview_semaphore
+    if _preview_semaphore is None:
+        _preview_semaphore = asyncio.Semaphore(5)
+    return _preview_semaphore
+
+# Pydantic models for request/response validation
+class ConnectRequest(BaseModel):
+    port: Optional[str] = None
+
+class auto_playModeRequest(BaseModel):
+    enabled: bool
+    playlist: Optional[str] = None
+    run_mode: Optional[str] = "loop"
+    pause_time: Optional[float] = 5.0
+    clear_pattern: Optional[str] = "adaptive"
+    shuffle: Optional[bool] = False
+
+class TimeSlot(BaseModel):
+    start_time: str  # HH:MM format
+    end_time: str    # HH:MM format
+    days: str        # "daily", "weekdays", "weekends", or "custom"
+    custom_days: Optional[List[str]] = []  # ["monday", "tuesday", etc.]
+
+class ScheduledPauseRequest(BaseModel):
+    enabled: bool
+    control_wled: Optional[bool] = False
+    finish_pattern: Optional[bool] = False  # Finish current pattern before pausing
+    timezone: Optional[str] = None  # IANA timezone or None for system default
+    time_slots: List[TimeSlot] = []
+
+class CoordinateRequest(BaseModel):
+    theta: float
+    rho: float
+
+class PlaylistRequest(BaseModel):
+    playlist_name: str
+    files: List[str] = []
+    pause_time: float = 0
+    clear_pattern: Optional[str] = None
+    run_mode: str = "single"
+    shuffle: bool = False
+
+class PlaylistRunRequest(BaseModel):
+    playlist_name: str
+    pause_time: Optional[float] = 0
+    clear_pattern: Optional[str] = None
+    run_mode: Optional[str] = "single"
+    shuffle: Optional[bool] = False
+    start_time: Optional[str] = None
+    end_time: Optional[str] = None
+
+class SpeedRequest(BaseModel):
+    speed: float
+
+class WLEDRequest(BaseModel):
+    wled_ip: Optional[str] = None
+
+class LEDConfigRequest(BaseModel):
+    provider: str  # "wled", "dw_leds", or "none"
+    ip_address: Optional[str] = None  # For WLED only
+    # DW LED specific fields
+    num_leds: Optional[int] = None
+    gpio_pin: Optional[int] = None
+    pixel_order: Optional[str] = None
+    brightness: Optional[int] = None
+
+class DeletePlaylistRequest(BaseModel):
+    playlist_name: str
+
+class RenamePlaylistRequest(BaseModel):
+    old_name: str
+    new_name: str
+
+class ThetaRhoRequest(BaseModel):
+    file_name: str
+    pre_execution: Optional[str] = "none"
+
+class GetCoordinatesRequest(BaseModel):
+    file_name: str
+
+# ============================================================================
+# Unified Settings Models
+# ============================================================================
+
+class AppSettingsUpdate(BaseModel):
+    name: Optional[str] = None
+    custom_logo: Optional[str] = None  # Filename or empty string to clear (favicon auto-generated)
+
+class PatternSettingsUpdate(BaseModel):
+    clear_pattern_speed: Optional[int] = None
+    custom_clear_from_in: Optional[str] = None
+    custom_clear_from_out: Optional[str] = None
+
+class ScheduledPauseSettingsUpdate(BaseModel):
+    enabled: Optional[bool] = None
+    control_wled: Optional[bool] = None
+    finish_pattern: Optional[bool] = None
+    timezone: Optional[str] = None  # IANA timezone (e.g., "America/New_York") or None for system default
+    time_slots: Optional[List[TimeSlot]] = None
+
+class HomingSettingsUpdate(BaseModel):
+    mode: Optional[int] = None
+    angular_offset_degrees: Optional[float] = None
+    home_on_connect: Optional[bool] = None  # Auto-home after connecting on startup
+    auto_home_enabled: Optional[bool] = None
+    auto_home_after_patterns: Optional[int] = None
+    hard_reset_theta: Optional[bool] = None  # Enable hard reset ($Bye) when resetting theta
+
+class LedSettingsUpdate(BaseModel):
+    provider: Optional[str] = None  # "none", "wled", "board"
+    wled_ip: Optional[str] = None
+    control_mode: Optional[str] = None  # "manual" or "automated"
+    idle_timeout_enabled: Optional[bool] = None
+    idle_timeout_minutes: Optional[int] = None
+
+class MqttSettingsUpdate(BaseModel):
+    enabled: Optional[bool] = None
+    broker: Optional[str] = None
+    port: Optional[int] = None
+    username: Optional[str] = None
+    password: Optional[str] = None  # Write-only, never returned in GET
+    client_id: Optional[str] = None
+    discovery_prefix: Optional[str] = None
+    device_id: Optional[str] = None
+    device_name: Optional[str] = None
+
+class MachineSettingsUpdate(BaseModel):
+    timezone: Optional[str] = None  # IANA timezone (e.g., "America/New_York", "UTC")
+
+class SecuritySettingsUpdate(BaseModel):
+    mode: Optional[str] = None  # "off", "lockdown", "play_only"
+    password: Optional[str] = None  # Write-only, stored as SHA-256 hash
+
+class SecurityVerifyRequest(BaseModel):
+    password: str
+
+class SettingsUpdate(BaseModel):
+    """Request model for PATCH /api/settings - all fields optional for partial updates"""
+    app: Optional[AppSettingsUpdate] = None
+    patterns: Optional[PatternSettingsUpdate] = None
+    scheduled_pause: Optional[ScheduledPauseSettingsUpdate] = None
+    homing: Optional[HomingSettingsUpdate] = None
+    led: Optional[LedSettingsUpdate] = None
+    mqtt: Optional[MqttSettingsUpdate] = None
+    machine: Optional[MachineSettingsUpdate] = None
+    security: Optional[SecuritySettingsUpdate] = None
+
+# Store active WebSocket connections
+active_status_connections = set()
+active_cache_progress_connections = set()
+
+@app.websocket("/ws/status")
+async def websocket_status_endpoint(websocket: WebSocket):
+    """Status stream. The board observer pushes every update via
+    broadcast_status_update; this handler only sends the cached snapshot on
+    connect and then holds the socket open."""
+    await websocket.accept()
+    active_status_connections.add(websocket)
+    try:
+        await websocket.send_json({
+            "type": "status_update",
+            "data": execution.get_cached_status()
+        })
+        while True:
+            # Drain any client messages (none are expected) until disconnect.
+            await websocket.receive_text()
+    except (WebSocketDisconnect, RuntimeError):
+        pass
+    finally:
+        active_status_connections.discard(websocket)
+        try:
+            await websocket.close()
+        except RuntimeError:
+            pass
+
+async def broadcast_status_update(status: dict):
+    """Broadcast status update to all connected clients."""
+    disconnected = set()
+    for websocket in active_status_connections:
+        try:
+            await websocket.send_json({
+                "type": "status_update",
+                "data": status
+            })
+        except WebSocketDisconnect:
+            disconnected.add(websocket)
+        except RuntimeError:
+            disconnected.add(websocket)
+    
+    active_status_connections.difference_update(disconnected)
+
+@app.websocket("/ws/cache-progress")
+async def websocket_cache_progress_endpoint(websocket: WebSocket):
+    from modules.core.cache_manager import get_cache_progress
+
+    await websocket.accept()
+    active_cache_progress_connections.add(websocket)
+    try:
+        while True:
+            progress = get_cache_progress()
+            try:
+                await websocket.send_json({
+                    "type": "cache_progress",
+                    "data": progress
+                })
+            except RuntimeError as e:
+                if "close message has been sent" in str(e):
+                    break
+                raise
+            await asyncio.sleep(1.0)  # Update every 1 second (reduced frequency for better performance)
+    except WebSocketDisconnect:
+        pass
+    finally:
+        active_cache_progress_connections.discard(websocket)
+        try:
+            await websocket.close()
+        except RuntimeError:
+            pass
+
+
+# WebSocket endpoint for real-time log streaming
+@app.websocket("/ws/logs")
+async def websocket_logs_endpoint(websocket: WebSocket):
+    """Stream application logs in real-time via WebSocket."""
+    await websocket.accept()
+
+    handler = get_memory_handler()
+    if not handler:
+        await websocket.close()
+        return
+
+    # Subscribe to log updates
+    log_queue = handler.subscribe()
+
+    try:
+        while True:
+            try:
+                # Wait for new log entry with timeout
+                log_entry = await asyncio.wait_for(log_queue.get(), timeout=30.0)
+                await websocket.send_json({
+                    "type": "log_entry",
+                    "data": log_entry
+                })
+            except asyncio.TimeoutError:
+                # Send heartbeat to keep connection alive
+                await websocket.send_json({"type": "heartbeat"})
+            except RuntimeError as e:
+                if "close message has been sent" in str(e):
+                    break
+                raise
+    except WebSocketDisconnect:
+        pass
+    finally:
+        handler.unsubscribe(log_queue)
+        try:
+            await websocket.close()
+        except RuntimeError:
+            pass
+
+
+# API endpoint to retrieve logs
+@app.get("/api/logs", tags=["logs"])
+async def get_logs(limit: int = 100, level: str = None, offset: int = 0):
+    """
+    Retrieve application logs from memory buffer with pagination.
+
+    Args:
+        limit: Maximum number of log entries to return (default: 100)
+        level: Filter by log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
+        offset: Number of entries to skip from newest (for lazy loading older logs)
+
+    Returns:
+        List of log entries with timestamp, level, logger, and message.
+        Also returns total count and whether there are more logs available.
+    """
+    handler = get_memory_handler()
+    if not handler:
+        return {"logs": [], "count": 0, "total": 0, "has_more": False, "error": "Log handler not initialized"}
+
+    # Clamp limit to reasonable range (no max limit for lazy loading)
+    limit = max(1, limit)
+    offset = max(0, offset)
+
+    logs = handler.get_logs(limit=limit, level=level, offset=offset)
+    total = handler.get_total_count(level=level)
+    has_more = offset + len(logs) < total
+
+    return {"logs": logs, "count": len(logs), "total": total, "has_more": has_more}
+
+
+@app.delete("/api/logs", tags=["logs"])
+async def clear_logs():
+    """Clear all logs from the memory buffer."""
+    handler = get_memory_handler()
+    if handler:
+        handler.clear()
+    return {"status": "ok", "message": "Logs cleared"}
+
+
+# FastAPI routes - Redirect old frontend routes to new React frontend on port 80
+def get_redirect_response(request: Request):
+    """Return redirect page pointing users to the new frontend."""
+    host = request.headers.get("host", "localhost").split(":")[0]  # Remove port if present
+    return templates.TemplateResponse("redirect.html", {"request": request, "host": host})
+
+@app.get("/")
+async def index(request: Request):
+    return get_redirect_response(request)
+
+@app.get("/settings")
+async def settings_page(request: Request):
+    return get_redirect_response(request)
+
+# ============================================================================
+# Unified Settings API
+# ============================================================================
+
+@app.get("/api/settings", tags=["settings"])
+async def get_all_settings():
+    """
+    Get all application settings in a unified structure.
+
+    This endpoint consolidates multiple settings endpoints into a single response.
+    Individual settings endpoints are deprecated but still functional.
+    """
+    return {
+        "app": {
+            "name": state.app_name,
+            "custom_logo": state.custom_logo
+        },
+        "patterns": {
+            "clear_pattern_speed": state.clear_pattern_speed,
+            "custom_clear_from_in": state.custom_clear_from_in,
+            "custom_clear_from_out": state.custom_clear_from_out
+        },
+        "scheduled_pause": {
+            "enabled": state.scheduled_pause_enabled,
+            "control_wled": state.scheduled_pause_control_wled,
+            "finish_pattern": state.scheduled_pause_finish_pattern,
+            "timezone": state.scheduled_pause_timezone,
+            "time_slots": state.scheduled_pause_time_slots
+        },
+        "homing": {
+            "mode": state.homing,
+            "user_override": state.homing_user_override,  # True if user explicitly set, False if auto-detected
+            "angular_offset_degrees": state.angular_homing_offset_degrees,
+            "home_on_connect": state.home_on_connect,
+            "auto_home_enabled": state.auto_home_enabled,
+            "auto_home_after_patterns": state.auto_home_after_patterns,
+            "hard_reset_theta": state.hard_reset_theta  # Enable hard reset when resetting theta
+        },
+        "led": {
+            "provider": state.led_provider,
+            "wled_ip": state.wled_ip,
+            "control_mode": state.dw_led_control_mode,
+            "idle_timeout_enabled": state.dw_led_idle_timeout_enabled,
+            "idle_timeout_minutes": state.dw_led_idle_timeout_minutes
+        },
+        "mqtt": {
+            "enabled": state.mqtt_enabled,
+            "broker": state.mqtt_broker,
+            "port": state.mqtt_port,
+            "username": state.mqtt_username,
+            "has_password": bool(state.mqtt_password),
+            "client_id": state.mqtt_client_id,
+            "discovery_prefix": state.mqtt_discovery_prefix,
+            "device_id": state.mqtt_device_id,
+            "device_name": state.mqtt_device_name
+        },
+        "machine": {
+            # Kinematics live in the board's config.yaml; the host only keeps a timezone.
+            "timezone": state.timezone,
+        },
+        "security": {
+            "mode": state.security_mode,
+            "has_password": bool(state.security_password_hash)
+        }
+    }
+
+@app.get("/api/manifest.webmanifest", tags=["settings"])
+async def get_dynamic_manifest():
+    """
+    Get a dynamically generated web manifest.
+
+    Returns manifest with custom icons and app name if custom branding is configured,
+    otherwise returns defaults.
+    """
+    # Determine icon paths based on whether custom logo exists
+    if state.custom_logo:
+        icon_base = "/static/custom"
+    else:
+        icon_base = "/static"
+
+    # Use custom app name or default
+    app_name = state.app_name or "Dune Weaver"
+
+    return {
+        "name": app_name,
+        "short_name": app_name,
+        "description": "Control your kinetic sand table",
+        "icons": [
+            {
+                "src": f"{icon_base}/android-chrome-192x192.png",
+                "sizes": "192x192",
+                "type": "image/png",
+                "purpose": "any"
+            },
+            {
+                "src": f"{icon_base}/android-chrome-512x512.png",
+                "sizes": "512x512",
+                "type": "image/png",
+                "purpose": "any"
+            },
+            {
+                "src": f"{icon_base}/android-chrome-192x192.png",
+                "sizes": "192x192",
+                "type": "image/png",
+                "purpose": "maskable"
+            },
+            {
+                "src": f"{icon_base}/android-chrome-512x512.png",
+                "sizes": "512x512",
+                "type": "image/png",
+                "purpose": "maskable"
+            }
+        ],
+        "start_url": "/",
+        "scope": "/",
+        "display": "standalone",
+        "orientation": "any",
+        "theme_color": "#0a0a0a",
+        "background_color": "#0a0a0a",
+        "categories": ["utilities", "entertainment"]
+    }
+
+@app.patch("/api/settings", tags=["settings"])
+async def update_settings(settings_update: SettingsUpdate):
+    """
+    Partially update application settings.
+
+    Only include the categories and fields you want to update.
+    All fields are optional - only provided values will be updated.
+
+    Example: {"app": {"name": "Dune Weaver"}, "auto_play": {"enabled": true}}
+    """
+    updated_categories = []
+    requires_restart = False
+    led_reinit_needed = False
+    old_led_provider = state.led_provider
+
+    # App settings
+    if settings_update.app:
+        if settings_update.app.name is not None:
+            state.app_name = settings_update.app.name or "Dune Weaver"
+        if settings_update.app.custom_logo is not None:
+            state.custom_logo = settings_update.app.custom_logo or None
+        updated_categories.append("app")
+
+    # Pattern settings
+    if settings_update.patterns:
+        p = settings_update.patterns
+        if p.clear_pattern_speed is not None:
+            state.clear_pattern_speed = p.clear_pattern_speed if p.clear_pattern_speed > 0 else None
+        if p.custom_clear_from_in is not None:
+            state.custom_clear_from_in = p.custom_clear_from_in or None
+        if p.custom_clear_from_out is not None:
+            state.custom_clear_from_out = p.custom_clear_from_out or None
+        # The firmware runs its own clear files; mirror custom choices onto them.
+        if p.custom_clear_from_in is not None or p.custom_clear_from_out is not None:
+            board_settings.push_custom_clears_async()
+        updated_categories.append("patterns")
+
+    # Scheduled pause (Still Sands) settings
+    if settings_update.scheduled_pause:
+        sp = settings_update.scheduled_pause
+        if sp.enabled is not None:
+            state.scheduled_pause_enabled = sp.enabled
+        if sp.control_wled is not None:
+            state.scheduled_pause_control_wled = sp.control_wled
+        if sp.finish_pattern is not None:
+            state.scheduled_pause_finish_pattern = sp.finish_pattern
+        if sp.timezone is not None:
+            # Empty string means use system default (store as None)
+            state.scheduled_pause_timezone = sp.timezone if sp.timezone else None
+            # Clear cached timezone in pattern_manager so it picks up the new setting
+            from modules.core import pattern_manager
+            pattern_manager._cached_timezone = None
+            pattern_manager._cached_zoneinfo = None
+        if sp.time_slots is not None:
+            state.scheduled_pause_time_slots = [slot.model_dump() for slot in sp.time_slots]
+        updated_categories.append("scheduled_pause")
+        # Board NVS is canonical for Still Sands (the mobile apps edit it there);
+        # push the new values, plus the timezone so board-local schedules match.
+        if state.conn:
+            def _push_sands():
+                try:
+                    board_settings.push_still_sands()
+                    if sp.timezone is not None:
+                        board_settings.sync_board_time()
+                except Exception as e:
+                    logger.warning(f"Could not push Still Sands settings to board: {e}")
+            asyncio.create_task(asyncio.to_thread(_push_sands))
+
+    # Homing settings
+    if settings_update.homing:
+        h = settings_update.homing
+        if h.mode is not None:
+            state.homing = h.mode
+            state.homing_user_override = True  # User explicitly set preference
+        if h.angular_offset_degrees is not None:
+            state.angular_homing_offset_degrees = h.angular_offset_degrees
+        if h.home_on_connect is not None:
+            state.home_on_connect = h.home_on_connect
+        if h.auto_home_enabled is not None:
+            state.auto_home_enabled = h.auto_home_enabled
+        if h.auto_home_after_patterns is not None:
+            state.auto_home_after_patterns = h.auto_home_after_patterns
+        if h.hard_reset_theta is not None:
+            state.hard_reset_theta = h.hard_reset_theta
+        updated_categories.append("homing")
+        # Mirror to the board: mode/offset now (idle-gated NVS; also re-pushed on
+        # every home), and the auto-home cadence for firmware-sequenced playlists.
+        if state.conn:
+            def _push_homing():
+                try:
+                    if h.mode is not None:
+                        state.conn.set_homing_mode("crash" if state.homing == 0 else "sensor")
+                    if h.angular_offset_degrees is not None:
+                        state.conn.set_theta_offset(state.angular_homing_offset_degrees)
+                    if h.auto_home_enabled is not None or h.auto_home_after_patterns is not None:
+                        board_settings.push_auto_home()
+                except Exception as e:
+                    logger.warning(f"Could not push homing settings to board: {e}")
+            asyncio.create_task(asyncio.to_thread(_push_homing))
+
+    # LED settings
+    if settings_update.led:
+        led = settings_update.led
+        if led.provider is not None:
+            state.led_provider = led.provider
+            if led.provider != old_led_provider:
+                led_reinit_needed = True
+        if led.wled_ip is not None:
+            state.wled_ip = led.wled_ip or None
+        if led.control_mode is not None:
+            state.dw_led_control_mode = led.control_mode
+        if led.idle_timeout_enabled is not None:
+            state.dw_led_idle_timeout_enabled = led.idle_timeout_enabled
+        if led.idle_timeout_minutes is not None:
+            state.dw_led_idle_timeout_minutes = led.idle_timeout_minutes
+        updated_categories.append("led")
+
+    # MQTT settings
+    if settings_update.mqtt:
+        m = settings_update.mqtt
+        if m.enabled is not None:
+            state.mqtt_enabled = m.enabled
+        if m.broker is not None:
+            state.mqtt_broker = m.broker
+        if m.port is not None:
+            state.mqtt_port = m.port
+        if m.username is not None:
+            state.mqtt_username = m.username
+        if m.password is not None:
+            state.mqtt_password = m.password
+        if m.client_id is not None:
+            state.mqtt_client_id = m.client_id
+        if m.discovery_prefix is not None:
+            state.mqtt_discovery_prefix = m.discovery_prefix
+        if m.device_id is not None:
+            state.mqtt_device_id = m.device_id
+        if m.device_name is not None:
+            state.mqtt_device_name = m.device_name
+        updated_categories.append("mqtt")
+        requires_restart = True
+
+    # Machine settings (kinematics live in the board's config.yaml; only the
+    # host timezone remains)
+    if settings_update.machine:
+        m = settings_update.machine
+        if m.timezone is not None:
+            # Validate timezone by trying to create a ZoneInfo object
+            try:
+                from zoneinfo import ZoneInfo
+            except ImportError:
+                from backports.zoneinfo import ZoneInfo
+            try:
+                ZoneInfo(m.timezone)  # Validate
+                state.timezone = m.timezone
+                # Also update scheduled_pause_timezone to keep in sync
+                state.scheduled_pause_timezone = m.timezone
+                # Clear cached timezone in pattern_manager so it picks up the new setting
+                from modules.core import pattern_manager
+                pattern_manager._cached_timezone = None
+                pattern_manager._cached_zoneinfo = None
+                logger.info(f"Timezone updated to: {m.timezone}")
+            except Exception as e:
+                logger.warning(f"Invalid timezone '{m.timezone}': {e}")
+        updated_categories.append("machine")
+
+    # Security settings
+    if settings_update.security:
+        sec = settings_update.security
+        if sec.mode is not None:
+            if sec.mode not in ("off", "lockdown", "play_only"):
+                raise HTTPException(status_code=400, detail="Invalid security mode. Must be 'off', 'lockdown', or 'play_only'.")
+            state.security_mode = sec.mode
+            # When turning off, clear the password hash
+            if sec.mode == "off":
+                state.security_password_hash = ""
+        if sec.password is not None and sec.password != "":
+            state.security_password_hash = hashlib.sha256(sec.password.encode('utf-8')).hexdigest()
+        updated_categories.append("security")
+
+    # Save state
+    state.save()
+
+    # Handle LED reinitialization if provider changed
+    if led_reinit_needed:
+        logger.info(f"LED provider changed from {old_led_provider} to {state.led_provider}, reinitialization may be needed")
+
+    logger.info(f"Settings updated: {', '.join(updated_categories)}")
+
+    return {
+        "success": True,
+        "updated_categories": updated_categories,
+        "requires_restart": requires_restart,
+        "led_reinit_needed": led_reinit_needed
+    }
+
+@app.post("/api/security/verify", tags=["settings"])
+async def verify_security_password(request: SecurityVerifyRequest):
+    """Verify a security password against the stored hash."""
+    if not state.security_password_hash:
+        return {"valid": False}
+    input_hash = hashlib.sha256(request.password.encode('utf-8')).hexdigest()
+    return {"valid": input_hash == state.security_password_hash}
+
+# ============================================================================
+# Board-owned settings (FluidNC NVS) — proxied for the web UI
+# ============================================================================
+
+class AutostartSettingsUpdate(BaseModel):
+    playlist: Optional[str] = None  # empty string disables auto-play on boot
+    run_mode: Optional[str] = None  # "single" | "loop"
+    shuffle: Optional[bool] = None
+    pause_seconds: Optional[int] = None
+    pause_from_start: Optional[bool] = None
+    clear_pattern: Optional[str] = None  # none|adaptive|in|out|sideway|random
+
+class BoardSettingsUpdate(BaseModel):
+    autostart: Optional[AutostartSettingsUpdate] = None
+
+@app.get("/api/board/settings", tags=["settings"])
+async def get_board_settings():
+    """
+    Read the board-owned settings (auto-play on boot, homing, clock) straight
+    from the FluidNC board's NVS. These fire on table power-on, independent of
+    this backend, and are shared with the native mobile apps.
+    """
+    if not state.conn:
+        return {"reachable": False}
+    try:
+        return await asyncio.to_thread(board_settings.get_board_settings)
+    except Exception as e:
+        logger.warning(f"Could not read board settings: {e}")
+        return {"reachable": False}
+
+@app.patch("/api/board/settings", tags=["settings"])
+async def update_board_settings(update: BoardSettingsUpdate):
+    """Write board-owned settings ($Playlist/Autostart* family) to the board."""
+    if not state.conn:
+        raise HTTPException(status_code=409, detail="Not connected to the board")
+    try:
+        if update.autostart:
+            autostart = update.autostart.model_dump(exclude_none=True)
+            await asyncio.to_thread(board_settings.apply_autostart, autostart)
+            # A newly selected boot playlist must exist on the board SD, with
+            # all of its patterns, before the next power-on.
+            playlist_name = autostart.get("playlist")
+            if playlist_name:
+                playlist = playlist_manager.get_playlist(playlist_name)
+                if playlist:
+                    asyncio.create_task(asyncio.to_thread(
+                        board_settings.mirror_playlist,
+                        playlist_name, playlist["files"], None, True,
+                    ))
+        return {"success": True}
+    except Exception as e:
+        logger.warning(f"Could not write board settings: {e}")
+        raise HTTPException(status_code=502, detail=f"Board rejected the update: {e}")
+
+class BoardCommandRequest(BaseModel):
+    command: str
+
+@app.post("/api/board/command", tags=["settings"])
+async def board_command(request: BoardCommandRequest):
+    """Advanced console: send a $-command to the board and return the recent
+    session log (the board streams command output to its log, not HTTP)."""
+    if not state.conn:
+        raise HTTPException(status_code=409, detail="Not connected to the board")
+    command = request.command.strip()
+    if not command:
+        raise HTTPException(status_code=400, detail="Empty command")
+    try:
+        response = await asyncio.to_thread(state.conn.run_command, command)
+        log_tail = ""
+        try:
+            log_text = await asyncio.to_thread(
+                lambda: state.conn._get("/sand_log").text)
+            log_tail = "\n".join(log_text.strip().splitlines()[-15:])
+        except Exception:
+            pass
+        responses = [line for line in (response or "").strip().splitlines() if line]
+        return {"success": True, "responses": responses, "log": log_tail}
+    except Exception as e:
+        raise HTTPException(status_code=502, detail=f"Board command failed: {e}")
+
+@app.post("/api/board/sync_time", tags=["settings"])
+async def sync_board_time():
+    """Push the host's clock and timezone to the board (quiet hours need it)."""
+    if not state.conn:
+        raise HTTPException(status_code=409, detail="Not connected to the board")
+    try:
+        result = await asyncio.to_thread(board_settings.sync_board_time)
+        return {"success": True, "time": result}
+    except Exception as e:
+        raise HTTPException(status_code=502, detail=f"Clock sync failed: {e}")
+
+# ============================================================================
+# Multi-Table Identity Endpoints
+# ============================================================================
+
+class TableInfoUpdate(BaseModel):
+    name: Optional[str] = None
+
+class KnownTableAdd(BaseModel):
+    id: str
+    name: str
+    url: str
+    host: Optional[str] = None
+    port: Optional[int] = None
+    version: Optional[str] = None
+
+class KnownTableUpdate(BaseModel):
+    name: Optional[str] = None
+
+@app.get("/api/table-info", tags=["multi-table"])
+async def get_table_info():
+    """
+    Get table identity information for multi-table discovery.
+
+    Returns the table's unique ID, name, and version.
+    """
+    return {
+        "id": state.table_id,
+        "name": state.table_name,
+        "version": await version_manager.get_current_version()
+    }
+
+@app.patch("/api/table-info", tags=["multi-table"])
+async def update_table_info(update: TableInfoUpdate):
+    """
+    Update table identity information.
+
+    Currently only the table name can be updated.
+    The table ID is immutable after generation.
+    """
+    if update.name is not None:
+        state.table_name = update.name.strip() or "Dune Weaver"
+        state.save()
+        logger.info(f"Table name updated to: {state.table_name}")
+        await mdns_discovery.update_name(state.table_name)
+
+    return {
+        "success": True,
+        "id": state.table_id,
+        "name": state.table_name
+    }
+
+@app.get("/api/discovered-tables", tags=["multi-table"])
+async def get_discovered_tables():
+    """
+    Get Dune Weaver tables auto-discovered via mDNS on the local network.
+
+    Unlike known-tables these are not persisted - the list reflects which
+    peer backends are currently advertising themselves. Returns an empty
+    list when mDNS is unavailable (e.g. zeroconf not installed).
+    """
+    return {"tables": mdns_discovery.get_tables()}
+
+@app.get("/api/known-tables", tags=["multi-table"])
+async def get_known_tables():
+    """
+    Get list of known remote tables.
+
+    These are tables that have been manually added and are persisted
+    for multi-table management.
+    """
+    return {"tables": state.known_tables}
+
+@app.post("/api/known-tables", tags=["multi-table"])
+async def add_known_table(table: KnownTableAdd):
+    """
+    Add a known remote table.
+
+    This persists the table information so it's available across
+    browser sessions and devices.
+    """
+    # Check if table with same ID already exists
+    existing_ids = [t.get("id") for t in state.known_tables]
+    if table.id in existing_ids:
+        raise HTTPException(status_code=400, detail="Table with this ID already exists")
+
+    # Check if table with same URL already exists
+    existing_urls = [t.get("url") for t in state.known_tables]
+    if table.url in existing_urls:
+        raise HTTPException(status_code=400, detail="Table with this URL already exists")
+
+    new_table = {
+        "id": table.id,
+        "name": table.name,
+        "url": table.url,
+    }
+    if table.host:
+        new_table["host"] = table.host
+    if table.port:
+        new_table["port"] = table.port
+    if table.version:
+        new_table["version"] = table.version
+
+    state.known_tables.append(new_table)
+    state.save()
+    logger.info(f"Added known table: {table.name} ({table.url})")
+
+    return {"success": True, "table": new_table}
+
+@app.delete("/api/known-tables/{table_id}", tags=["multi-table"])
+async def remove_known_table(table_id: str):
+    """
+    Remove a known remote table by ID.
+    """
+    original_count = len(state.known_tables)
+    state.known_tables = [t for t in state.known_tables if t.get("id") != table_id]
+
+    if len(state.known_tables) == original_count:
+        raise HTTPException(status_code=404, detail="Table not found")
+
+    state.save()
+    logger.info(f"Removed known table: {table_id}")
+
+    return {"success": True}
+
+@app.patch("/api/known-tables/{table_id}", tags=["multi-table"])
+async def update_known_table(table_id: str, update: KnownTableUpdate):
+    """
+    Update a known remote table's name.
+    """
+    for table in state.known_tables:
+        if table.get("id") == table_id:
+            if update.name is not None:
+                table["name"] = update.name.strip()
+            state.save()
+            logger.info(f"Updated known table {table_id}: name={update.name}")
+            return {"success": True, "table": table}
+
+    raise HTTPException(status_code=404, detail="Table not found")
+
+# ============================================================================
+# Individual Settings Endpoints (Deprecated - use /api/settings instead)
+# ============================================================================
+
+@app.get("/api/scheduled-pause", deprecated=True, tags=["settings-deprecated"])
+async def get_scheduled_pause():
+    """DEPRECATED: Use GET /api/settings instead. Get current Still Sands settings."""
+    return {
+        "enabled": state.scheduled_pause_enabled,
+        "control_wled": state.scheduled_pause_control_wled,
+        "finish_pattern": state.scheduled_pause_finish_pattern,
+        "timezone": state.scheduled_pause_timezone,
+        "time_slots": state.scheduled_pause_time_slots
+    }
+
+@app.post("/api/scheduled-pause", deprecated=True, tags=["settings-deprecated"])
+async def set_scheduled_pause(request: ScheduledPauseRequest):
+    """Update Still Sands settings."""
+    try:
+        # Validate time slots
+        for i, slot in enumerate(request.time_slots):
+            # Validate time format (HH:MM)
+            try:
+                datetime.strptime(slot.start_time, "%H:%M")
+                datetime.strptime(slot.end_time, "%H:%M")
+            except ValueError:
+                raise HTTPException(
+                    status_code=400,
+                    detail=f"Invalid time format in slot {i+1}. Use HH:MM format."
+                )
+
+            # Validate days setting
+            if slot.days not in ["daily", "weekdays", "weekends", "custom"]:
+                raise HTTPException(
+                    status_code=400,
+                    detail=f"Invalid days setting in slot {i+1}. Must be 'daily', 'weekdays', 'weekends', or 'custom'."
+                )
+
+            # Validate custom days if applicable
+            if slot.days == "custom":
+                if not slot.custom_days or len(slot.custom_days) == 0:
+                    raise HTTPException(
+                        status_code=400,
+                        detail=f"Custom days must be specified for slot {i+1} when days is set to 'custom'."
+                    )
+
+                valid_days = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
+                for day in slot.custom_days:
+                    if day not in valid_days:
+                        raise HTTPException(
+                            status_code=400,
+                            detail=f"Invalid day '{day}' in slot {i+1}. Valid days are: {', '.join(valid_days)}"
+                        )
+
+        # Update state
+        state.scheduled_pause_enabled = request.enabled
+        state.scheduled_pause_control_wled = request.control_wled
+        state.scheduled_pause_finish_pattern = request.finish_pattern
+        state.scheduled_pause_timezone = request.timezone if request.timezone else None
+        state.scheduled_pause_time_slots = [slot.model_dump() for slot in request.time_slots]
+        state.save()
+
+        # Clear cached timezone so it picks up the new setting
+        from modules.core import pattern_manager
+        pattern_manager._cached_timezone = None
+        pattern_manager._cached_zoneinfo = None
+
+        wled_msg = " (with WLED control)" if request.control_wled else ""
+        finish_msg = " (finish pattern first)" if request.finish_pattern else ""
+        tz_msg = f" (timezone: {request.timezone})" if request.timezone else ""
+        logger.info(f"Still Sands {'enabled' if request.enabled else 'disabled'} with {len(request.time_slots)} time slots{wled_msg}{finish_msg}{tz_msg}")
+        return {"success": True, "message": "Still Sands settings updated"}
+
+    except HTTPException:
+        raise
+    except Exception as e:
+        logger.error(f"Error updating Still Sands settings: {str(e)}")
+        raise HTTPException(status_code=500, detail=f"Failed to update Still Sands settings: {str(e)}")
+
+@app.get("/api/homing-config", deprecated=True, tags=["settings-deprecated"])
+async def get_homing_config():
+    """Get homing configuration (mode, compass offset, and auto-home settings)."""
+    return {
+        "homing_mode": state.homing,
+        "angular_homing_offset_degrees": state.angular_homing_offset_degrees,
+        "auto_home_enabled": state.auto_home_enabled,
+        "auto_home_after_patterns": state.auto_home_after_patterns
+    }
+
+class HomingConfigRequest(BaseModel):
+    homing_mode: int = 0  # 0 = crash, 1 = sensor
+    angular_homing_offset_degrees: float = 0.0
+    auto_home_enabled: Optional[bool] = None
+    auto_home_after_patterns: Optional[int] = None
+
+@app.post("/api/homing-config", deprecated=True, tags=["settings-deprecated"])
+async def set_homing_config(request: HomingConfigRequest):
+    """Set homing configuration (mode, compass offset, and auto-home settings)."""
+    try:
+        # Validate homing mode
+        if request.homing_mode not in [0, 1]:
+            raise HTTPException(status_code=400, detail="Homing mode must be 0 (crash) or 1 (sensor)")
+
+        state.homing = request.homing_mode
+        state.homing_user_override = True  # User explicitly set preference
+        state.angular_homing_offset_degrees = request.angular_homing_offset_degrees
+
+        # Update auto-home settings if provided
+        if request.auto_home_enabled is not None:
+            state.auto_home_enabled = request.auto_home_enabled
+        if request.auto_home_after_patterns is not None:
+            if request.auto_home_after_patterns < 1:
+                raise HTTPException(status_code=400, detail="Auto-home after patterns must be at least 1")
+            state.auto_home_after_patterns = request.auto_home_after_patterns
+
+        state.save()
+
+        mode_name = "crash" if request.homing_mode == 0 else "sensor"
+        logger.info(f"Homing mode set to {mode_name}, compass offset set to {request.angular_homing_offset_degrees}°")
+        if request.auto_home_enabled is not None:
+            logger.info(f"Auto-home enabled: {state.auto_home_enabled}, after {state.auto_home_after_patterns} patterns")
+        return {"success": True, "message": "Homing configuration updated"}
+    except HTTPException:
+        raise
+    except Exception as e:
+        logger.error(f"Error updating homing configuration: {str(e)}")
+        raise HTTPException(status_code=500, detail=f"Failed to update homing configuration: {str(e)}")
+
+@app.get("/list_serial_ports")
+async def list_ports():
+    logger.debug("Listing available serial ports")
+    return await asyncio.to_thread(connection_manager.list_serial_ports)
+
+@app.post("/connect")
+async def connect(request: ConnectRequest):
+    # `request.port` carries the board address now (a URL or bare IP). Blank =>
+    # use the configured/default board URL.
+    from modules.connection.fluidnc_client import FluidNCClient
+    try:
+        url = connection_manager._normalize_board_url(request.port or "") or connection_manager.board_url()
+        state.board_url = url
+        state.port = url
+        state.save()
+        state.conn = FluidNCClient(url)
+        if not state.conn.reachable():
+            state.conn = None
+            raise HTTPException(status_code=500, detail=f"Board not reachable at {url}")
+        if not await asyncio.to_thread(connection_manager.device_init, False):
+            raise HTTPException(status_code=500, detail="Failed to initialize board")
+        logger.info(f"Successfully connected to board at {url}")
+        return {"success": True}
+    except HTTPException:
+        raise
+    except Exception as e:
+        logger.error(f"Failed to connect to board: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/disconnect")
+async def disconnect():
+    try:
+        state.conn.close()
+        logger.info('Successfully disconnected from serial port')
+        return {"success": True}
+    except Exception as e:
+        logger.error(f'Failed to disconnect serial: {str(e)}')
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/restart_connection")
+async def restart(request: ConnectRequest):
+    if not request.port:
+        logger.warning("Restart serial request received without port")
+        raise HTTPException(status_code=400, detail="No port provided")
+
+    try:
+        logger.info(f"Restarting connection on port {request.port}")
+        connection_manager.restart_connection()
+        return {"success": True}
+    except Exception as e:
+        logger.error(f"Failed to restart serial on port {request.port}: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+@app.get("/list_theta_rho_files")
+async def list_theta_rho_files():
+    logger.debug("Listing theta-rho files")
+    # Run the blocking file system operation in a thread pool
+    files = await asyncio.to_thread(pattern_manager.list_theta_rho_files)
+    return sorted(files)
+
+@app.get("/list_theta_rho_files_with_metadata")
+async def list_theta_rho_files_with_metadata():
+    """Get list of theta-rho files with metadata for sorting and filtering.
+    
+    Optimized to process files asynchronously and support request cancellation.
+    """
+    from modules.core.cache_manager import get_pattern_metadata
+    import asyncio
+    from concurrent.futures import ThreadPoolExecutor
+    
+    # Run the blocking file listing in a thread
+    files = await asyncio.to_thread(pattern_manager.list_theta_rho_files)
+    files_with_metadata = []
+
+    # Use ThreadPoolExecutor for I/O-bound operations
+    executor = ThreadPoolExecutor(max_workers=4)
+    
+    def process_file(file_path):
+        """Process a single file and return its metadata."""
+        try:
+            full_path = os.path.join(pattern_manager.THETA_RHO_DIR, file_path)
+            
+            # Get file stats
+            file_stat = os.stat(full_path)
+            
+            # Get cached metadata (this should be fast if cached)
+            metadata = get_pattern_metadata(file_path)
+            
+            # Extract full folder path from file path
+            path_parts = file_path.split('/')
+            if len(path_parts) > 1:
+                # Get everything except the filename (join all folder parts)
+                category = '/'.join(path_parts[:-1])
+            else:
+                category = 'root'
+            
+            # Get file name without extension
+            file_name = os.path.splitext(os.path.basename(file_path))[0]
+            
+            # Use modification time (mtime) for "date modified"
+            date_modified = file_stat.st_mtime
+            
+            return {
+                'path': file_path,
+                'name': file_name,
+                'category': category,
+                'date_modified': date_modified,
+                'coordinates_count': metadata.get('total_coordinates', 0) if metadata else 0
+            }
+            
+        except Exception as e:
+            logger.warning(f"Error getting metadata for {file_path}: {str(e)}")
+            # Include file with minimal info if metadata fails
+            path_parts = file_path.split('/')
+            if len(path_parts) > 1:
+                category = '/'.join(path_parts[:-1])
+            else:
+                category = 'root'
+            return {
+                'path': file_path,
+                'name': os.path.splitext(os.path.basename(file_path))[0],
+                'category': category,
+                'date_modified': 0,
+                'coordinates_count': 0
+            }
+    
+    # Load the entire metadata cache at once (async)
+    # This is much faster than 1000+ individual metadata lookups
+    try:
+        import json
+        metadata_cache_path = "metadata_cache.json"
+        # Use async file reading to avoid blocking the event loop
+        cache_data = await asyncio.to_thread(lambda: json.load(open(metadata_cache_path, 'r')))
+        cache_dict = cache_data.get('data', {})
+        logger.debug(f"Loaded metadata cache with {len(cache_dict)} entries")
+
+        # Process all files using cached data only
+        for file_path in files:
+            try:
+                # Extract category from path
+                path_parts = file_path.split('/')
+                category = '/'.join(path_parts[:-1]) if len(path_parts) > 1 else 'root'
+
+                # Get file name without extension
+                file_name = os.path.splitext(os.path.basename(file_path))[0]
+
+                # Get metadata from cache
+                cached_entry = cache_dict.get(file_path, {})
+                if isinstance(cached_entry, dict) and 'metadata' in cached_entry:
+                    metadata = cached_entry['metadata']
+                    coords_count = metadata.get('total_coordinates', 0)
+                    date_modified = cached_entry.get('mtime', 0)
+                else:
+                    coords_count = 0
+                    date_modified = 0
+
+                files_with_metadata.append({
+                    'path': file_path,
+                    'name': file_name,
+                    'category': category,
+                    'date_modified': date_modified,
+                    'coordinates_count': coords_count
+                })
+
+            except Exception as e:
+                logger.warning(f"Error processing {file_path}: {e}")
+                # Include file with minimal info if processing fails
+                path_parts = file_path.split('/')
+                category = '/'.join(path_parts[:-1]) if len(path_parts) > 1 else 'root'
+                files_with_metadata.append({
+                    'path': file_path,
+                    'name': os.path.splitext(os.path.basename(file_path))[0],
+                    'category': category,
+                    'date_modified': 0,
+                    'coordinates_count': 0
+                })
+
+    except Exception as e:
+        logger.error(f"Failed to load metadata cache, falling back to slow method: {e}")
+        # Fallback to original method if cache loading fails
+        # Create tasks only when needed
+        loop = asyncio.get_running_loop()
+        tasks = [loop.run_in_executor(executor, process_file, file_path) for file_path in files]
+
+        for task in asyncio.as_completed(tasks):
+            try:
+                result = await task
+                files_with_metadata.append(result)
+            except Exception as task_error:
+                logger.error(f"Error processing file: {str(task_error)}")
+
+    # Clean up executor
+    executor.shutdown(wait=False)
+
+    return files_with_metadata
+
+@app.post("/upload_theta_rho")
+async def upload_theta_rho(file: UploadFile = File(...)):
+    """Upload a theta-rho file."""
+    try:
+        # Save the file
+        # Ensure custom_patterns directory exists
+        custom_patterns_dir = os.path.join(pattern_manager.THETA_RHO_DIR, "custom_patterns")
+        os.makedirs(custom_patterns_dir, exist_ok=True)
+        
+        # Use forward slashes for internal path representation to maintain consistency
+        file_path_in_patterns_dir = f"custom_patterns/{file.filename}"
+        full_file_path = os.path.join(pattern_manager.THETA_RHO_DIR, file_path_in_patterns_dir)
+        
+        # Save the uploaded file with proper encoding for Windows compatibility
+        file_content = await file.read()
+        try:
+            # First try to decode as UTF-8 and re-encode to ensure proper encoding
+            text_content = file_content.decode('utf-8')
+            with open(full_file_path, "w", encoding='utf-8') as f:
+                f.write(text_content)
+        except UnicodeDecodeError:
+            # If UTF-8 decoding fails, save as binary (fallback)
+            with open(full_file_path, "wb") as f:
+                f.write(file_content)
+        
+        logger.info(f"File {file.filename} saved successfully")
+        
+        # Generate image preview for the new file with retry logic
+        max_retries = 3
+        for attempt in range(max_retries):
+            try:
+                logger.info(f"Generating preview for {file_path_in_patterns_dir} (attempt {attempt + 1}/{max_retries})")
+                success = await generate_image_preview(file_path_in_patterns_dir)
+                if success:
+                    logger.info(f"Preview generated successfully for {file_path_in_patterns_dir}")
+                    break
+                else:
+                    logger.warning(f"Preview generation failed for {file_path_in_patterns_dir} (attempt {attempt + 1})")
+                    if attempt < max_retries - 1:
+                        await asyncio.sleep(0.5)  # Small delay before retry
+            except Exception as e:
+                logger.error(f"Error generating preview for {file_path_in_patterns_dir} (attempt {attempt + 1}): {str(e)}")
+                if attempt < max_retries - 1:
+                    await asyncio.sleep(0.5)  # Small delay before retry
+        
+        return {"success": True, "message": f"File {file.filename} uploaded successfully"}
+    except Exception as e:
+        logger.error(f"Error uploading file: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/get_theta_rho_coordinates")
+async def get_theta_rho_coordinates(request: GetCoordinatesRequest):
+    """Get theta-rho coordinates for animated preview."""
+    try:
+        # Normalize file path for cross-platform compatibility and remove prefixes
+        file_name = normalize_file_path(request.file_name)
+        file_path = os.path.join(THETA_RHO_DIR, file_name)
+
+        # Check if we can use cached coordinates (already loaded for current playback)
+        # This avoids re-parsing large files (2MB+) which can cause issues on Pi Zero 2W
+        current_file = state.current_playing_file
+        if current_file and state._current_coordinates:
+            # Normalize current file path for comparison
+            current_normalized = normalize_file_path(current_file)
+            if current_normalized == file_name:
+                logger.debug(f"Using cached coordinates for {file_name}")
+                return {
+                    "success": True,
+                    "coordinates": state._current_coordinates,
+                    "total_points": len(state._current_coordinates)
+                }
+
+        # Check file existence asynchronously
+        exists = await asyncio.to_thread(os.path.exists, file_path)
+        if not exists:
+            raise HTTPException(status_code=404, detail=f"File {file_name} not found")
+
+        # Parse the theta-rho file in a thread (not process) to avoid memory pressure
+        # on resource-constrained devices like Pi Zero 2W
+        coordinates = await asyncio.to_thread(parse_theta_rho_file, file_path)
+
+        if not coordinates:
+            raise HTTPException(status_code=400, detail="No valid coordinates found in file")
+
+        return {
+            "success": True,
+            "coordinates": coordinates,
+            "total_points": len(coordinates)
+        }
+
+    except Exception as e:
+        logger.error(f"Error getting coordinates for {request.file_name}: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/run_theta_rho")
+async def run_theta_rho(request: ThetaRhoRequest):
+    """Run one pattern. The firmware sequences the pre-execution clear
+    ($Sand/Run clear=<mode>) and aborts any current job first."""
+    if not request.file_name:
+        logger.warning('Run theta-rho request received without file name')
+        raise HTTPException(status_code=400, detail="No file name provided")
+
+    normalized_file_name = normalize_file_path(request.file_name)
+    file_path = os.path.join(pattern_manager.THETA_RHO_DIR, normalized_file_name)
+    if not os.path.exists(file_path):
+        logger.error(f'Theta-rho file not found: {file_path}')
+        raise HTTPException(status_code=404, detail="File not found")
+
+    if not (state.conn.is_connected() if state.conn else False):
+        logger.warning("Attempted to run a pattern without a connection")
+        raise HTTPException(status_code=400, detail="Connection not established")
+    check_homing_in_progress()
+
+    try:
+        await execution.run_pattern(file_path, request.pre_execution)
+        return {"success": True}
+    except execution.ExecutionError as e:
+        raise HTTPException(status_code=409, detail=str(e))
+    except Exception as e:
+        logger.error(f'Failed to run theta-rho file {request.file_name}: {str(e)}')
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/stop_execution")
+async def stop_execution():
+    if not (state.conn.is_connected() if state.conn else False):
+        logger.warning("Attempted to stop without a connection")
+        raise HTTPException(status_code=400, detail="Connection not established")
+    try:
+        success = await execution.stop()
+    except execution.ExecutionError as e:
+        raise HTTPException(status_code=500, detail=str(e))
+    if not success:
+        raise HTTPException(status_code=500, detail="Stop timed out - use force_stop")
+    return {"success": True}
+
+@app.post("/force_stop")
+async def force_stop():
+    """Best-effort board stop + unconditional host-state reset. Use when the
+    normal stop doesn't come back."""
+    logger.info("Force stop requested - clearing all run state")
+    await execution.stop(force=True)
+    state.is_homing = False
+    return {"success": True, "message": "Force stop completed"}
+
+@app.post("/soft_reset")
+async def soft_reset():
+    """Send $Bye soft reset to FluidNC controller. Resets position counters to 0."""
+    if not (state.conn and state.conn.is_connected()):
+        logger.warning("Attempted to soft reset without a connection")
+        raise HTTPException(status_code=400, detail="Connection not established")
+
+    try:
+        # Stop any running patterns first
+        await execution.stop(force=True)
+
+        # Use the shared soft reset function
+        await connection_manager.perform_soft_reset()
+
+        return {"success": True, "message": "Soft reset sent. Position reset to 0."}
+    except Exception as e:
+        logger.error(f"Error sending soft reset: {e}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/send_home")
+async def send_home():
+    try:
+        if not (state.conn.is_connected() if state.conn else False):
+            logger.warning("Attempted to move to home without a connection")
+            raise HTTPException(status_code=400, detail="Connection not established")
+
+        if state.is_homing:
+            raise HTTPException(status_code=409, detail="Homing already in progress")
+
+        # Set homing flag to block other movement operations
+        state.is_homing = True
+        logger.info("Homing started - blocking other movement operations")
+
+        try:
+            # Run homing with 15 second timeout
+            success = await asyncio.to_thread(connection_manager.home)
+            if not success:
+                logger.error("Homing failed or timed out")
+                raise HTTPException(status_code=500, detail="Homing failed or timed out after 15 seconds")
+
+            return {"success": True}
+        finally:
+            # Always clear homing flag when done (success or failure)
+            state.is_homing = False
+            logger.info("Homing completed - movement operations unblocked")
+    except HTTPException:
+        raise
+    except Exception as e:
+        logger.error(f"Failed to send home command: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+class SensorHomingRecoveryRequest(BaseModel):
+    switch_to_crash_homing: bool = False
+
+@app.post("/recover_sensor_homing")
+async def recover_sensor_homing(request: SensorHomingRecoveryRequest):
+    """
+    Recover from sensor homing failure.
+
+    If switch_to_crash_homing is True, changes homing mode to crash homing (mode 0)
+    and saves the setting. Then attempts to reconnect and home the device.
+
+    If switch_to_crash_homing is False, just clears the failure flag and retries
+    with sensor homing.
+    """
+    try:
+        # Clear the sensor homing failure flag first
+        state.sensor_homing_failed = False
+
+        if request.switch_to_crash_homing:
+            # Switch to crash homing mode
+            logger.info("Switching to crash homing mode per user request")
+            state.homing = 0
+            state.homing_user_override = True
+            state.save()
+
+        # If already connected, just perform homing
+        if state.conn and state.conn.is_connected():
+            logger.info("Device already connected, performing homing...")
+            state.is_homing = True
+            try:
+                success = await asyncio.to_thread(connection_manager.home)
+                if not success:
+                    # Check if sensor homing failed again
+                    if state.sensor_homing_failed:
+                        return {
+                            "success": False,
+                            "sensor_homing_failed": True,
+                            "message": "Sensor homing failed again. Please check sensor position or switch to crash homing."
+                        }
+                    return {"success": False, "message": "Homing failed"}
+                return {"success": True, "message": "Homing completed successfully"}
+            finally:
+                state.is_homing = False
+        else:
+            # Need to reconnect
+            logger.info("Reconnecting device and performing homing...")
+            state.is_homing = True
+            try:
+                # connect_device includes homing
+                await asyncio.to_thread(connection_manager.connect_device, True)
+
+                # Check if sensor homing failed during connection
+                if state.sensor_homing_failed:
+                    return {
+                        "success": False,
+                        "sensor_homing_failed": True,
+                        "message": "Sensor homing failed. Please check sensor position or switch to crash homing."
+                    }
+
+                if state.conn and state.conn.is_connected():
+                    return {"success": True, "message": "Connected and homed successfully"}
+                else:
+                    return {"success": False, "message": "Failed to establish connection"}
+            finally:
+                state.is_homing = False
+
+    except Exception as e:
+        logger.error(f"Error during sensor homing recovery: {e}")
+        state.is_homing = False
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/run_theta_rho_file/{file_name}")
+async def run_specific_theta_rho_file(file_name: str):
+    file_path = os.path.join(pattern_manager.THETA_RHO_DIR, file_name)
+    if not os.path.exists(file_path):
+        raise HTTPException(status_code=404, detail="File not found")
+
+    if not (state.conn.is_connected() if state.conn else False):
+        logger.warning("Attempted to run a pattern without a connection")
+        raise HTTPException(status_code=400, detail="Connection not established")
+
+    check_homing_in_progress()
+
+    await execution.run_pattern(file_path)
+    return {"success": True}
+
+class DeleteFileRequest(BaseModel):
+    file_name: str
+
+@app.post("/delete_theta_rho_file")
+async def delete_theta_rho_file(request: DeleteFileRequest):
+    if not request.file_name:
+        logger.warning("Delete theta-rho file request received without filename")
+        raise HTTPException(status_code=400, detail="No file name provided")
+
+    # Normalize file path for cross-platform compatibility
+    normalized_file_name = normalize_file_path(request.file_name)
+    file_path = os.path.join(pattern_manager.THETA_RHO_DIR, normalized_file_name)
+
+    # Check file existence asynchronously
+    exists = await asyncio.to_thread(os.path.exists, file_path)
+    if not exists:
+        logger.error(f"Attempted to delete non-existent file: {file_path}")
+        raise HTTPException(status_code=404, detail="File not found")
+
+    try:
+        # Delete the pattern file asynchronously
+        await asyncio.to_thread(os.remove, file_path)
+        logger.info(f"Successfully deleted theta-rho file: {request.file_name}")
+        
+        # Clean up cached preview image and metadata asynchronously
+        from modules.core.cache_manager import delete_pattern_cache
+        cache_cleanup_success = await asyncio.to_thread(delete_pattern_cache, normalized_file_name)
+        if cache_cleanup_success:
+            logger.info(f"Successfully cleaned up cache for {request.file_name}")
+        else:
+            logger.warning(f"Cache cleanup failed for {request.file_name}, but pattern was deleted")
+        
+        return {"success": True, "cache_cleanup": cache_cleanup_success}
+    except Exception as e:
+        logger.error(f"Failed to delete theta-rho file {request.file_name}: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/move_to_center")
+async def move_to_center():
+    try:
+        if not (state.conn.is_connected() if state.conn else False):
+            logger.warning("Attempted to move to center without a connection")
+            raise HTTPException(status_code=400, detail="Connection not established")
+
+        check_homing_in_progress()
+
+
+        logger.info("Moving device to center position")
+        await pattern_manager.reset_theta()
+        await pattern_manager.move_polar(0, 0)
+
+        # Wait for machine to reach idle before returning
+        idle = await connection_manager.check_idle_async(timeout=60)
+        if not idle:
+            logger.warning("Machine did not reach idle after move to center")
+
+        return {"success": True}
+    except HTTPException:
+        raise
+    except Exception as e:
+        logger.error(f"Failed to move to center: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/move_to_perimeter")
+async def move_to_perimeter():
+    try:
+        if not (state.conn.is_connected() if state.conn else False):
+            logger.warning("Attempted to move to perimeter without a connection")
+            raise HTTPException(status_code=400, detail="Connection not established")
+
+        check_homing_in_progress()
+
+
+        logger.info("Moving device to perimeter position")
+        await pattern_manager.reset_theta()
+        await pattern_manager.move_polar(0, 1)
+
+        # Wait for machine to reach idle before returning
+        idle = await connection_manager.check_idle_async(timeout=60)
+        if not idle:
+            logger.warning("Machine did not reach idle after move to perimeter")
+
+        return {"success": True}
+    except HTTPException:
+        raise
+    except Exception as e:
+        logger.error(f"Failed to move to perimeter: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/preview_thr")
+async def preview_thr(request: DeleteFileRequest):
+    if not request.file_name:
+        logger.warning("Preview theta-rho request received without filename")
+        raise HTTPException(status_code=400, detail="No file name provided")
+
+    # Normalize file path for cross-platform compatibility
+    normalized_file_name = normalize_file_path(request.file_name)
+    # Construct the full path to the pattern file to check existence
+    pattern_file_path = os.path.join(pattern_manager.THETA_RHO_DIR, normalized_file_name)
+
+    # Check file existence asynchronously
+    exists = await asyncio.to_thread(os.path.exists, pattern_file_path)
+    if not exists:
+        logger.error(f"Attempted to preview non-existent pattern file: {pattern_file_path}")
+        raise HTTPException(status_code=404, detail="Pattern file not found")
+
+    try:
+        cache_path = get_cache_path(normalized_file_name)
+
+        # Check cache existence asynchronously
+        cache_exists = await asyncio.to_thread(os.path.exists, cache_path)
+        if not cache_exists:
+            logger.info(f"Cache miss for {request.file_name}. Generating preview...")
+            # Attempt to generate the preview if it's missing
+            success = await generate_image_preview(normalized_file_name)
+            cache_exists_after = await asyncio.to_thread(os.path.exists, cache_path)
+            if not success or not cache_exists_after:
+                logger.error(f"Failed to generate or find preview for {request.file_name} after attempting generation.")
+                raise HTTPException(status_code=500, detail="Failed to generate preview image.")
+
+        # Try to get coordinates from metadata cache first
+        metadata = get_pattern_metadata(normalized_file_name)
+        if metadata:
+            first_coord_obj = metadata.get('first_coordinate')
+            last_coord_obj = metadata.get('last_coordinate')
+        else:
+            # Fallback to parsing file if metadata not cached (shouldn't happen after initial cache)
+            logger.debug(f"Metadata cache miss for {request.file_name}, parsing file")
+            coordinates = await asyncio.to_thread(parse_theta_rho_file, pattern_file_path)
+            first_coord = coordinates[0] if coordinates else None
+            last_coord = coordinates[-1] if coordinates else None
+            
+            # Format coordinates as objects with x and y properties
+            first_coord_obj = {"x": first_coord[0], "y": first_coord[1]} if first_coord else None
+            last_coord_obj = {"x": last_coord[0], "y": last_coord[1]} if last_coord else None
+
+        # Return JSON with preview URL and coordinates
+        # URL encode the file_name for the preview URL
+        # Handle both forward slashes and backslashes for cross-platform compatibility
+        encoded_filename = normalized_file_name.replace('\\', '--').replace('/', '--')
+        return {
+            "preview_url": f"/preview/{encoded_filename}",
+            "first_coordinate": first_coord_obj,
+            "last_coordinate": last_coord_obj
+        }
+
+    except HTTPException:
+        raise
+    except Exception as e:
+        logger.error(f"Failed to generate or serve preview for {request.file_name}: {str(e)}")
+        raise HTTPException(status_code=500, detail=f"Failed to serve preview image: {str(e)}")
+
+@app.get("/api/pattern_history/{pattern_name:path}")
+async def get_pattern_history(pattern_name: str):
+    """Get the most recent execution history for a pattern.
+
+    Returns the last completed execution time and speed for the given pattern.
+    """
+    from modules.core.pattern_manager import get_pattern_execution_history
+
+    # Get just the filename if a full path was provided
+    filename = os.path.basename(pattern_name)
+    if not filename.endswith('.thr'):
+        filename = f"{filename}.thr"
+
+    history = get_pattern_execution_history(filename)
+    if history:
+        return history
+    return {"actual_time_seconds": None, "actual_time_formatted": None, "speed": None, "timestamp": None}
+
+@app.get("/api/pattern_history_all")
+async def get_all_pattern_history():
+    """Get execution history for all patterns in a single request.
+
+    Returns a dict mapping pattern names to their most recent execution history.
+    """
+    from modules.core.pattern_manager import EXECUTION_LOG_FILE
+
+    if not os.path.exists(EXECUTION_LOG_FILE):
+        return {}
+
+    try:
+        history_map = {}
+        play_counts = {}
+        with open(EXECUTION_LOG_FILE, 'r') as f:
+            for line in f:
+                line = line.strip()
+                if not line:
+                    continue
+                try:
+                    entry = json.loads(line)
+                    # Only consider fully completed patterns
+                    if entry.get('completed', False):
+                        pattern_name = entry.get('pattern_name')
+                        if pattern_name:
+                            play_counts[pattern_name] = play_counts.get(pattern_name, 0) + 1
+                            # Keep the most recent match (last one in file wins)
+                            history_map[pattern_name] = {
+                                "actual_time_seconds": entry.get('actual_time_seconds'),
+                                "actual_time_formatted": entry.get('actual_time_formatted'),
+                                "speed": entry.get('speed'),
+                                "timestamp": entry.get('timestamp'),
+                                "play_count": play_counts[pattern_name],
+                                "last_played": entry.get('timestamp')
+                            }
+                except json.JSONDecodeError:
+                    continue
+        return history_map
+    except Exception as e:
+        logger.error(f"Failed to read execution time log: {e}")
+        return {}
+
+@app.get("/preview/{encoded_filename}")
+async def serve_preview(encoded_filename: str):
+    """Serve a preview image for a pattern file."""
+    # Decode the filename by replacing -- with the original path separators
+    # First try forward slash (most common case), then backslash if needed
+    file_name = encoded_filename.replace('--', '/')
+    
+    # Apply normalization to handle any remaining path prefixes
+    file_name = normalize_file_path(file_name)
+    
+    # Check if the decoded path exists, if not try backslash decoding
+    cache_path = get_cache_path(file_name)
+    if not os.path.exists(cache_path):
+        # Try with backslash for Windows paths
+        file_name_backslash = encoded_filename.replace('--', '\\')
+        file_name_backslash = normalize_file_path(file_name_backslash)
+        cache_path_backslash = get_cache_path(file_name_backslash)
+        if os.path.exists(cache_path_backslash):
+            file_name = file_name_backslash
+            cache_path = cache_path_backslash
+    # cache_path is already determined above in the decoding logic
+    if not os.path.exists(cache_path):
+        logger.error(f"Preview image not found for {file_name}")
+        raise HTTPException(status_code=404, detail="Preview image not found")
+    
+    # Add caching headers
+    headers = {
+        "Cache-Control": "public, max-age=31536000",  # Cache for 1 year
+        "Content-Type": "image/webp",
+        "Accept-Ranges": "bytes"
+    }
+    
+    return FileResponse(
+        cache_path,
+        media_type="image/webp",
+        headers=headers
+    )
+
+@app.post("/send_coordinate")
+async def send_coordinate(request: CoordinateRequest):
+    if not (state.conn.is_connected() if state.conn else False):
+        logger.warning("Attempted to send coordinate without a connection")
+        raise HTTPException(status_code=400, detail="Connection not established")
+
+    check_homing_in_progress()
+
+
+    try:
+        logger.debug(f"Sending coordinate: theta={request.theta}, rho={request.rho}")
+        await pattern_manager.move_polar(request.theta, request.rho)
+
+        # Wait for machine to reach idle before returning
+        idle = await connection_manager.check_idle_async(timeout=60)
+        if not idle:
+            logger.warning("Machine did not reach idle after send_coordinate")
+
+        return {"success": True}
+    except Exception as e:
+        logger.error(f"Failed to send coordinate: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.get("/download/{filename}")
+async def download_file(filename: str):
+    return FileResponse(
+        os.path.join(pattern_manager.THETA_RHO_DIR, filename),
+        filename=filename
+    )
+
+@app.get("/serial_status")
+async def serial_status():
+    """Connection status. `port` is the configured board address (HTTP, not serial)."""
+    connected = state.conn.is_connected() if state.conn else False
+    port = connection_manager.board_url()
+    logger.debug(f"Connection status check - connected: {connected}, board: {port}")
+    return {
+        "connected": connected,
+        "port": port,
+    }
+
+@app.post("/pause_execution")
+async def pause_execution():
+    status = execution.get_cached_status()
+    if not (status.get("is_running") or status.get("pause_time_remaining")):
+        raise HTTPException(status_code=400, detail="Nothing is currently playing")
+    try:
+        await execution.pause()
+        return {"success": True, "message": "Execution paused"}
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=f"Failed to pause execution: {e}")
+
+@app.post("/resume_execution")
+async def resume_execution():
+    if not execution.get_cached_status().get("is_paused"):
+        raise HTTPException(status_code=400, detail="Execution is not paused")
+    try:
+        await execution.resume()
+        return {"success": True, "message": "Execution resumed"}
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=f"Failed to resume execution: {e}")
+
+# Playlist endpoints
+@app.get("/list_all_playlists")
+async def list_all_playlists():
+    playlist_names = playlist_manager.list_all_playlists()
+    return playlist_names
+
+@app.get("/get_playlist")
+async def get_playlist(name: str):
+    if not name:
+        raise HTTPException(status_code=400, detail="Missing playlist name parameter")
+
+    playlist = playlist_manager.get_playlist(name)
+    if not playlist:
+        # Auto-create empty playlist if not found
+        logger.info(f"Playlist '{name}' not found, creating empty playlist")
+        playlist_manager.create_playlist(name, [])
+        playlist = {"name": name, "files": []}
+
+    return playlist
+
+@app.post("/create_playlist")
+async def create_playlist(request: PlaylistRequest):
+    success = playlist_manager.create_playlist(request.playlist_name, request.files)
+    return {
+        "success": success,
+        "message": f"Playlist '{request.playlist_name}' created/updated"
+    }
+
+@app.post("/modify_playlist")
+async def modify_playlist(request: PlaylistRequest):
+    success = playlist_manager.modify_playlist(request.playlist_name, request.files)
+    return {
+        "success": success,
+        "message": f"Playlist '{request.playlist_name}' updated"
+    }
+
+@app.delete("/delete_playlist")
+async def delete_playlist(request: DeletePlaylistRequest):
+    success = playlist_manager.delete_playlist(request.playlist_name)
+    if not success:
+        raise HTTPException(
+            status_code=404,
+            detail=f"Playlist '{request.playlist_name}' not found"
+        )
+
+    return {
+        "success": True,
+        "message": f"Playlist '{request.playlist_name}' deleted"
+    }
+
+@app.post("/rename_playlist")
+async def rename_playlist(request: RenamePlaylistRequest):
+    """Rename an existing playlist."""
+    success, message = playlist_manager.rename_playlist(request.old_name, request.new_name)
+    if not success:
+        raise HTTPException(
+            status_code=400,
+            detail=message
+        )
+
+    return {
+        "success": True,
+        "message": message,
+        "new_name": request.new_name
+    }
+
+class AddToPlaylistRequest(BaseModel):
+    playlist_name: str
+    pattern: str
+
+@app.post("/add_to_playlist")
+async def add_to_playlist(request: AddToPlaylistRequest):
+    success = playlist_manager.add_to_playlist(request.playlist_name, request.pattern)
+    if not success:
+        raise HTTPException(status_code=404, detail="Playlist not found")
+    return {"success": True}
+
+@app.post("/run_playlist")
+async def run_playlist_endpoint(request: PlaylistRequest):
+    """Run a playlist on the board ($Playlist/Run; the firmware sequences it)."""
+    if not (state.conn.is_connected() if state.conn else False):
+        logger.warning("Attempted to run a playlist without a connection")
+        raise HTTPException(status_code=400, detail="Connection not established")
+    check_homing_in_progress()
+
+    try:
+        await execution.start_playlist(
+            request.playlist_name,
+            run_mode=request.run_mode,
+            pause_time=request.pause_time,
+            clear_pattern=request.clear_pattern,
+            shuffle=request.shuffle,
+        )
+        return {"message": f"Started playlist: {request.playlist_name}"}
+    except execution.ExecutionError as e:
+        detail = str(e)
+        status = 404 if "not found" in detail else 409
+        raise HTTPException(status_code=status, detail=detail)
+    except Exception as e:
+        logger.error(f"Error running playlist: {e}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/set_speed")
+async def set_speed(request: SpeedRequest):
+    try:
+        if not (state.conn.is_connected() if state.conn else False):
+            logger.warning("Attempted to change speed without a connection")
+            raise HTTPException(status_code=400, detail="Connection not established")
+
+        if request.speed <= 0:
+            logger.warning(f"Invalid speed value received: {request.speed}")
+            raise HTTPException(status_code=400, detail="Invalid speed value")
+
+        state.speed = request.speed
+        # Push the feed live to the board (works mid-pattern; persists across the run).
+        try:
+            await asyncio.to_thread(state.conn.set_feed, int(request.speed))
+        except Exception as e:
+            logger.warning(f"Could not push feed to board: {e}")
+        return {"success": True, "speed": request.speed}
+    except HTTPException:
+        raise  # Re-raise HTTPException as-is
+    except Exception as e:
+        logger.error(f"Failed to set speed: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.get("/check_software_update")
+async def check_updates():
+    update_info = update_manager.check_git_updates()
+    return update_info
+
+@app.post("/update_software")
+async def update_software():
+    logger.info("Starting software update process")
+    success, error_message, error_log = update_manager.update_software()
+    
+    if success:
+        logger.info("Software update completed successfully")
+        return {"success": True}
+    else:
+        logger.error(f"Software update failed: {error_message}\nDetails: {error_log}")
+        raise HTTPException(
+            status_code=500,
+            detail={
+                "error": error_message,
+                "details": error_log
+            }
+        )
+
+@app.post("/set_wled_ip")
+async def set_wled_ip(request: WLEDRequest):
+    """Legacy endpoint for backward compatibility - sets WLED as LED provider"""
+    state.wled_ip = request.wled_ip
+    state.led_provider = "wled" if request.wled_ip else "none"
+    state.led_controller = LEDInterface("wled", request.wled_ip) if request.wled_ip else None
+    if state.led_controller:
+        state.led_controller.effect_idle()
+        _start_idle_led_timeout()
+    state.save()
+    logger.info(f"WLED IP updated: {request.wled_ip}")
+    return {"success": True, "wled_ip": state.wled_ip}
+
+@app.get("/get_wled_ip")
+async def get_wled_ip():
+    """Legacy endpoint for backward compatibility"""
+    if not state.wled_ip:
+        raise HTTPException(status_code=404, detail="No WLED IP set")
+    return {"success": True, "wled_ip": state.wled_ip}
+
+@app.post("/set_led_config", deprecated=True, tags=["settings-deprecated"])
+async def set_led_config(request: LEDConfigRequest):
+    """DEPRECATED: Use PATCH /api/settings instead. Configure LED provider (board, WLED, or none)"""
+    if request.provider not in ["wled", "board", "none"]:
+        raise HTTPException(status_code=400, detail="Invalid provider. Must be 'board', 'wled', or 'none'")
+
+    state.led_provider = request.provider
+
+    if request.provider == "wled":
+        if not request.ip_address:
+            raise HTTPException(status_code=400, detail="IP address required for WLED")
+        state.wled_ip = request.ip_address
+        state.led_controller = LEDInterface("wled", request.ip_address)
+        logger.info(f"LED provider set to WLED at {request.ip_address}")
+
+    elif request.provider == "board":
+        # The table's own LED ring, driven by the FluidNC firmware.
+        state.led_controller = LEDInterface("board")
+        logger.info("LED provider set to the table's built-in LEDs (firmware-controlled)")
+
+    else:  # none
+        state.wled_ip = None
+        state.led_controller = None
+        logger.info("LED provider disabled")
+
+    # Show idle effect if controller is configured
+    if state.led_controller:
+        state.led_controller.effect_idle()
+        _start_idle_led_timeout()
+
+    state.save()
+
+    return {
+        "success": True,
+        "provider": state.led_provider,
+        "wled_ip": state.wled_ip,
+    }
+
+@app.get("/get_led_config", deprecated=True, tags=["settings-deprecated"])
+async def get_led_config():
+    """DEPRECATED: Use GET /api/settings instead. Get current LED provider configuration"""
+    # Auto-detect provider for backward compatibility with existing installations
+    provider = state.led_provider
+    if not provider or provider == "none":
+        # If no provider set but we have IPs configured, auto-detect
+        if state.wled_ip:
+            provider = "wled"
+            state.led_provider = "wled"
+            state.save()
+            logger.info("Auto-detected WLED provider from existing configuration")
+        else:
+            provider = "none"
+
+    return {
+        "success": True,
+        "provider": provider,
+        "wled_ip": state.wled_ip,
+    }
+
+@app.post("/skip_pattern")
+async def skip_pattern():
+    try:
+        skipped = await execution.skip()
+    except execution.ExecutionError as e:
+        raise HTTPException(status_code=400, detail=str(e))
+    if not skipped:
+        raise HTTPException(status_code=400, detail="No playlist is currently running")
+    return {"success": True}
+
+@app.get("/api/custom_clear_patterns", deprecated=True, tags=["settings-deprecated"])
+async def get_custom_clear_patterns():
+    """Get the currently configured custom clear patterns."""
+    return {
+        "success": True,
+        "custom_clear_from_in": state.custom_clear_from_in,
+        "custom_clear_from_out": state.custom_clear_from_out
+    }
+
+@app.post("/api/custom_clear_patterns", deprecated=True, tags=["settings-deprecated"])
+async def set_custom_clear_patterns(request: dict):
+    """Set custom clear patterns for clear_from_in and clear_from_out."""
+    try:
+        # Validate that the patterns exist if they're provided
+        if "custom_clear_from_in" in request and request["custom_clear_from_in"]:
+            pattern_path = os.path.join(pattern_manager.THETA_RHO_DIR, request["custom_clear_from_in"])
+            if not os.path.exists(pattern_path):
+                raise HTTPException(status_code=400, detail=f"Pattern file not found: {request['custom_clear_from_in']}")
+            state.custom_clear_from_in = request["custom_clear_from_in"]
+        elif "custom_clear_from_in" in request:
+            state.custom_clear_from_in = None
+            
+        if "custom_clear_from_out" in request and request["custom_clear_from_out"]:
+            pattern_path = os.path.join(pattern_manager.THETA_RHO_DIR, request["custom_clear_from_out"])
+            if not os.path.exists(pattern_path):
+                raise HTTPException(status_code=400, detail=f"Pattern file not found: {request['custom_clear_from_out']}")
+            state.custom_clear_from_out = request["custom_clear_from_out"]
+        elif "custom_clear_from_out" in request:
+            state.custom_clear_from_out = None
+        
+        state.save()
+        # The firmware runs its own clear files; mirror the choice onto them.
+        board_settings.push_custom_clears_async()
+        logger.info(f"Custom clear patterns updated - in: {state.custom_clear_from_in}, out: {state.custom_clear_from_out}")
+        return {
+            "success": True,
+            "custom_clear_from_in": state.custom_clear_from_in,
+            "custom_clear_from_out": state.custom_clear_from_out
+        }
+    except Exception as e:
+        logger.error(f"Failed to set custom clear patterns: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.get("/api/clear_pattern_speed", deprecated=True, tags=["settings-deprecated"])
+async def get_clear_pattern_speed():
+    """Get the current clearing pattern speed setting."""
+    return {
+        "success": True,
+        "clear_pattern_speed": state.clear_pattern_speed,
+        "effective_speed": state.clear_pattern_speed if state.clear_pattern_speed is not None else state.speed
+    }
+
+@app.post("/api/clear_pattern_speed", deprecated=True, tags=["settings-deprecated"])
+async def set_clear_pattern_speed(request: dict):
+    """DEPRECATED: Use PATCH /api/settings instead. Set the clearing pattern speed."""
+    try:
+        # If speed is None or "none", use default behavior (state.speed)
+        speed_value = request.get("clear_pattern_speed")
+        if speed_value is None or speed_value == "none" or speed_value == "":
+            speed = None
+        else:
+            speed = int(speed_value)
+        
+        # Validate speed range (same as regular speed limits) only if speed is not None
+        if speed is not None and not (50 <= speed <= 2000):
+            raise HTTPException(status_code=400, detail="Speed must be between 50 and 2000")
+        
+        state.clear_pattern_speed = speed
+        state.save()
+        
+        logger.info(f"Clear pattern speed set to {speed if speed is not None else 'default (state.speed)'}")
+        return {
+            "success": True,
+            "clear_pattern_speed": state.clear_pattern_speed,
+            "effective_speed": state.clear_pattern_speed if state.clear_pattern_speed is not None else state.speed
+        }
+    except ValueError:
+        raise HTTPException(status_code=400, detail="Invalid speed value")
+    except Exception as e:
+        logger.error(f"Failed to set clear pattern speed: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.get("/api/app-name", deprecated=True, tags=["settings-deprecated"])
+async def get_app_name():
+    """DEPRECATED: Use GET /api/settings instead. Get current application name."""
+    return {"app_name": state.app_name}
+
+@app.post("/api/app-name", deprecated=True, tags=["settings-deprecated"])
+async def set_app_name(request: dict):
+    """DEPRECATED: Use PATCH /api/settings instead. Update application name."""
+    app_name = request.get("app_name", "").strip()
+    if not app_name:
+        app_name = "Dune Weaver"  # Reset to default if empty
+
+    state.app_name = app_name
+    state.save()
+
+    logger.info(f"Application name updated to: {app_name}")
+    return {"success": True, "app_name": app_name}
+
+# ============================================================================
+# Custom Branding Upload Endpoints
+# ============================================================================
+
+CUSTOM_BRANDING_DIR = os.path.join("static", "custom")
+ALLOWED_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"}
+MAX_LOGO_SIZE = 10 * 1024 * 1024  # 10MB
+MAX_LOGO_DIMENSION = 512  # Max width/height for optimized logo
+
+
+def optimize_logo_image(content: bytes, original_ext: str) -> tuple[bytes, str]:
+    """Optimize logo image by resizing and converting to WebP.
+
+    Args:
+        content: Original image bytes
+        original_ext: Original file extension (e.g., '.png', '.jpg')
+
+    Returns:
+        Tuple of (optimized_bytes, new_extension)
+
+    For SVG files, returns the original content unchanged.
+    For raster images, resizes to MAX_LOGO_DIMENSION and converts to WebP.
+    """
+    # SVG files are already lightweight vectors - keep as-is
+    if original_ext.lower() == ".svg":
+        return content, original_ext
+
+    try:
+        from PIL import Image
+        import io
+
+        with Image.open(io.BytesIO(content)) as img:
+            # Convert to RGBA for transparency support
+            if img.mode in ('P', 'LA') or (img.mode == 'RGBA' and 'transparency' in img.info):
+                img = img.convert('RGBA')
+            elif img.mode != 'RGBA':
+                img = img.convert('RGB')
+
+            # Resize if larger than max dimension (maintain aspect ratio)
+            width, height = img.size
+            if width > MAX_LOGO_DIMENSION or height > MAX_LOGO_DIMENSION:
+                ratio = min(MAX_LOGO_DIMENSION / width, MAX_LOGO_DIMENSION / height)
+                new_size = (int(width * ratio), int(height * ratio))
+                img = img.resize(new_size, Image.Resampling.LANCZOS)
+                logger.info(f"Logo resized from {width}x{height} to {new_size[0]}x{new_size[1]}")
+
+            # Save as WebP with good quality/size balance
+            output = io.BytesIO()
+            img.save(output, format='WEBP', quality=85, method=6)
+            optimized_bytes = output.getvalue()
+
+            original_size = len(content)
+            new_size = len(optimized_bytes)
+            reduction = ((original_size - new_size) / original_size) * 100
+            logger.info(f"Logo optimized: {original_size:,} bytes -> {new_size:,} bytes ({reduction:.1f}% reduction)")
+
+            return optimized_bytes, ".webp"
+
+    except Exception as e:
+        logger.warning(f"Logo optimization failed, using original: {str(e)}")
+        return content, original_ext
+
+def generate_favicon_from_logo(logo_path: str, output_dir: str) -> bool:
+    """Generate circular favicons with transparent background from the uploaded logo.
+
+    Creates:
+    - favicon.ico (multi-size: 256, 128, 64, 48, 32, 16)
+    - favicon-16x16.png, favicon-32x32.png, favicon-96x96.png, favicon-128x128.png
+
+    Returns True on success, False on failure.
+    """
+    try:
+        from PIL import Image, ImageDraw
+
+        def create_circular_transparent(img, size):
+            """Create circular image with transparent background."""
+            resized = img.resize((size, size), Image.Resampling.LANCZOS)
+
+            mask = Image.new('L', (size, size), 0)
+            draw = ImageDraw.Draw(mask)
+            draw.ellipse((0, 0, size - 1, size - 1), fill=255)
+
+            output = Image.new('RGBA', (size, size), (0, 0, 0, 0))
+            output.paste(resized, (0, 0), mask)
+            return output
+
+        with Image.open(logo_path) as img:
+            # Convert to RGBA if needed
+            if img.mode != 'RGBA':
+                img = img.convert('RGBA')
+
+            # Crop to square (center crop)
+            width, height = img.size
+            min_dim = min(width, height)
+            left = (width - min_dim) // 2
+            top = (height - min_dim) // 2
+            img = img.crop((left, top, left + min_dim, top + min_dim))
+
+            # Generate circular favicon PNGs with transparent background
+            png_sizes = {
+                "favicon-16x16.png": 16,
+                "favicon-32x32.png": 32,
+                "favicon-96x96.png": 96,
+                "favicon-128x128.png": 128,
+            }
+            for filename, size in png_sizes.items():
+                icon = create_circular_transparent(img, size)
+                icon.save(os.path.join(output_dir, filename), format='PNG')
+
+            # Generate high-resolution favicon.ico
+            ico_sizes = [256, 128, 64, 48, 32, 16]
+            ico_images = [create_circular_transparent(img, s) for s in ico_sizes]
+            ico_images[0].save(
+                os.path.join(output_dir, "favicon.ico"),
+                format='ICO',
+                append_images=ico_images[1:],
+                sizes=[(s, s) for s in ico_sizes]
+            )
+
+        return True
+    except Exception as e:
+        logger.error(f"Failed to generate favicon: {str(e)}")
+        return False
+
+def generate_pwa_icons_from_logo(logo_path: str, output_dir: str) -> bool:
+    """Generate square PWA app icons from the uploaded logo.
+
+    Creates square icons (no circular crop) - OS will apply its own mask.
+    Composites onto a solid background to avoid transparency issues
+    (iOS fills transparent areas with white on home screen icons).
+
+    Generates:
+    - apple-touch-icon.png (180x180)
+    - android-chrome-192x192.png (192x192)
+    - android-chrome-512x512.png (512x512)
+
+    Returns True on success, False on failure.
+    """
+    try:
+        from PIL import Image
+
+        with Image.open(logo_path) as img:
+            # Convert to RGBA if needed
+            if img.mode != 'RGBA':
+                img = img.convert('RGBA')
+
+            # Crop to square (center crop)
+            width, height = img.size
+            min_dim = min(width, height)
+            left = (width - min_dim) // 2
+            top = (height - min_dim) // 2
+            img = img.crop((left, top, left + min_dim, top + min_dim))
+
+            # Generate square icons at each required size
+            icon_sizes = {
+                "apple-touch-icon.png": 180,
+                "android-chrome-192x192.png": 192,
+                "android-chrome-512x512.png": 512,
+            }
+
+            for filename, size in icon_sizes.items():
+                resized = img.resize((size, size), Image.Resampling.LANCZOS)
+                # Composite onto solid background to eliminate transparency
+                # (iOS shows white behind transparent areas on home screen)
+                background = Image.new('RGB', (size, size), (10, 10, 10))  # #0a0a0a theme color
+                background.paste(resized, (0, 0), resized)  # Use resized as its own alpha mask
+                icon_path = os.path.join(output_dir, filename)
+                background.save(icon_path, format='PNG')
+                logger.info(f"Generated PWA icon: {filename}")
+
+        return True
+    except Exception as e:
+        logger.error(f"Failed to generate PWA icons: {str(e)}")
+        return False
+
+@app.post("/api/upload-logo", tags=["settings"])
+async def upload_logo(file: UploadFile = File(...)):
+    """Upload a custom logo image.
+
+    Supported formats: PNG, JPG, JPEG, GIF, WebP, SVG
+    Maximum upload size: 10MB
+
+    Images are automatically optimized:
+    - Resized to max 512x512 pixels
+    - Converted to WebP format for smaller file size
+    - SVG files are kept as-is (already lightweight)
+
+    A favicon and PWA icons will be automatically generated from the logo.
+    """
+    try:
+        # Validate file extension
+        file_ext = os.path.splitext(file.filename)[1].lower()
+        if file_ext not in ALLOWED_IMAGE_EXTENSIONS:
+            raise HTTPException(
+                status_code=400,
+                detail=f"Invalid file type. Allowed: {', '.join(ALLOWED_IMAGE_EXTENSIONS)}"
+            )
+
+        # Read and validate file size
+        content = await file.read()
+        if len(content) > MAX_LOGO_SIZE:
+            raise HTTPException(
+                status_code=400,
+                detail=f"File too large. Maximum size: {MAX_LOGO_SIZE // (1024*1024)}MB"
+            )
+
+        # Ensure custom branding directory exists
+        os.makedirs(CUSTOM_BRANDING_DIR, exist_ok=True)
+
+        # Delete old logo and favicon if they exist
+        if state.custom_logo:
+            old_logo_path = os.path.join(CUSTOM_BRANDING_DIR, state.custom_logo)
+            if os.path.exists(old_logo_path):
+                os.remove(old_logo_path)
+            # Also remove old favicon
+            old_favicon_path = os.path.join(CUSTOM_BRANDING_DIR, "favicon.ico")
+            if os.path.exists(old_favicon_path):
+                os.remove(old_favicon_path)
+
+        # Optimize the image (resize + convert to WebP for smaller file size)
+        optimized_content, optimized_ext = optimize_logo_image(content, file_ext)
+
+        # Generate a unique filename to prevent caching issues
+        import uuid
+        filename = f"logo-{uuid.uuid4().hex[:8]}{optimized_ext}"
+        file_path = os.path.join(CUSTOM_BRANDING_DIR, filename)
+
+        # Save the optimized logo file
+        with open(file_path, "wb") as f:
+            f.write(optimized_content)
+
+        # Generate favicon and PWA icons from logo (for non-SVG files)
+        favicon_generated = False
+        pwa_icons_generated = False
+        if optimized_ext != ".svg":
+            favicon_generated = generate_favicon_from_logo(file_path, CUSTOM_BRANDING_DIR)
+            pwa_icons_generated = generate_pwa_icons_from_logo(file_path, CUSTOM_BRANDING_DIR)
+
+        # Update state
+        state.custom_logo = filename
+        state.save()
+
+        logger.info(f"Custom logo uploaded: {filename}, favicon generated: {favicon_generated}, PWA icons generated: {pwa_icons_generated}")
+        return {
+            "success": True,
+            "filename": filename,
+            "url": f"/static/custom/{filename}",
+            "favicon_generated": favicon_generated,
+            "pwa_icons_generated": pwa_icons_generated
+        }
+
+    except HTTPException:
+        raise
+    except Exception as e:
+        logger.error(f"Error uploading logo: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.delete("/api/custom-logo", tags=["settings"])
+async def delete_custom_logo():
+    """Remove custom logo, favicon, and PWA icons, reverting to defaults."""
+    try:
+        if state.custom_logo:
+            # Remove logo
+            logo_path = os.path.join(CUSTOM_BRANDING_DIR, state.custom_logo)
+            if os.path.exists(logo_path):
+                os.remove(logo_path)
+
+            # Remove generated favicons
+            favicon_files = [
+                "favicon.ico",
+                "favicon-16x16.png",
+                "favicon-32x32.png",
+                "favicon-96x96.png",
+                "favicon-128x128.png",
+            ]
+            for favicon_name in favicon_files:
+                favicon_path = os.path.join(CUSTOM_BRANDING_DIR, favicon_name)
+                if os.path.exists(favicon_path):
+                    os.remove(favicon_path)
+
+            # Remove generated PWA icons
+            pwa_icons = [
+                "apple-touch-icon.png",
+                "android-chrome-192x192.png",
+                "android-chrome-512x512.png",
+            ]
+            for icon_name in pwa_icons:
+                icon_path = os.path.join(CUSTOM_BRANDING_DIR, icon_name)
+                if os.path.exists(icon_path):
+                    os.remove(icon_path)
+
+            state.custom_logo = None
+            state.save()
+            logger.info("Custom logo, favicon, and PWA icons removed")
+        return {"success": True}
+    except Exception as e:
+        logger.error(f"Error removing logo: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.get("/api/mqtt-config", deprecated=True, tags=["settings-deprecated"])
+async def get_mqtt_config():
+    """DEPRECATED: Use GET /api/settings instead. Get current MQTT configuration.
+
+    Note: Password is not returned for security reasons.
+    """
+    from modules.mqtt import get_mqtt_handler
+    handler = get_mqtt_handler()
+
+    return {
+        "enabled": state.mqtt_enabled,
+        "broker": state.mqtt_broker,
+        "port": state.mqtt_port,
+        "username": state.mqtt_username,
+        # Password is intentionally omitted for security
+        "has_password": bool(state.mqtt_password),
+        "client_id": state.mqtt_client_id,
+        "discovery_prefix": state.mqtt_discovery_prefix,
+        "device_id": state.mqtt_device_id,
+        "device_name": state.mqtt_device_name,
+        "connected": handler.is_connected if hasattr(handler, 'is_connected') else False,
+        "is_mock": handler.__class__.__name__ == 'MockMQTTHandler'
+    }
+
+@app.post("/api/mqtt-config", deprecated=True, tags=["settings-deprecated"])
+async def set_mqtt_config(request: dict):
+    """DEPRECATED: Use PATCH /api/settings instead. Update MQTT configuration. Requires restart to take effect."""
+    try:
+        # Update state with new values
+        state.mqtt_enabled = request.get("enabled", False)
+        state.mqtt_broker = (request.get("broker") or "").strip()
+        state.mqtt_port = int(request.get("port") or 1883)
+        state.mqtt_username = (request.get("username") or "").strip()
+        state.mqtt_password = (request.get("password") or "").strip()
+        state.mqtt_client_id = (request.get("client_id") or "dune_weaver").strip()
+        state.mqtt_discovery_prefix = (request.get("discovery_prefix") or "homeassistant").strip()
+        state.mqtt_device_id = (request.get("device_id") or "dune_weaver").strip()
+        state.mqtt_device_name = (request.get("device_name") or "Dune Weaver").strip()
+
+        # Validate required fields when enabled
+        if state.mqtt_enabled and not state.mqtt_broker:
+            return JSONResponse(
+                content={"success": False, "message": "Broker address is required when MQTT is enabled"},
+                status_code=400
+            )
+
+        state.save()
+        logger.info(f"MQTT configuration updated. Enabled: {state.mqtt_enabled}, Broker: {state.mqtt_broker}")
+
+        return {
+            "success": True,
+            "message": "MQTT configuration saved. Restart the application for changes to take effect.",
+            "requires_restart": True
+        }
+    except ValueError as e:
+        return JSONResponse(
+            content={"success": False, "message": f"Invalid value: {str(e)}"},
+            status_code=400
+        )
+    except Exception as e:
+        logger.error(f"Failed to update MQTT config: {str(e)}")
+        return JSONResponse(
+            content={"success": False, "message": str(e)},
+            status_code=500
+        )
+
+@app.post("/api/mqtt-test")
+async def test_mqtt_connection(request: dict):
+    """Test MQTT connection with provided settings."""
+    import paho.mqtt.client as mqtt_client
+
+    broker = (request.get("broker") or "").strip()
+    port = int(request.get("port") or 1883)
+    username = (request.get("username") or "").strip()
+    password = (request.get("password") or "").strip()
+    client_id = (request.get("client_id") or "dune_weaver_test").strip()
+
+    if not broker:
+        return JSONResponse(
+            content={"success": False, "message": "Broker address is required"},
+            status_code=400
+        )
+
+    try:
+        # Create a test client
+        client = mqtt_client.Client(client_id=client_id + "_test")
+
+        if username:
+            client.username_pw_set(username, password)
+
+        # Connection result
+        connection_result = {"connected": False, "error": None}
+
+        def on_connect(client, userdata, flags, rc):
+            if rc == 0:
+                connection_result["connected"] = True
+            else:
+                error_messages = {
+                    1: "Incorrect protocol version",
+                    2: "Invalid client identifier",
+                    3: "Server unavailable",
+                    4: "Bad username or password",
+                    5: "Not authorized"
+                }
+                connection_result["error"] = error_messages.get(rc, f"Connection failed with code {rc}")
+
+        client.on_connect = on_connect
+
+        # Try to connect with timeout
+        client.connect_async(broker, port, keepalive=10)
+        client.loop_start()
+
+        # Wait for connection result (max 5 seconds)
+        import time
+        start_time = time.time()
+        while time.time() - start_time < 5:
+            if connection_result["connected"] or connection_result["error"]:
+                break
+            await asyncio.sleep(0.1)
+
+        client.loop_stop()
+        client.disconnect()
+
+        if connection_result["connected"]:
+            return {"success": True, "message": "Successfully connected to MQTT broker"}
+        elif connection_result["error"]:
+            return JSONResponse(
+                content={"success": False, "message": connection_result["error"]},
+                status_code=400
+            )
+        else:
+            return JSONResponse(
+                content={"success": False, "message": "Connection timed out. Check broker address and port."},
+                status_code=400
+            )
+
+    except Exception as e:
+        logger.error(f"MQTT test connection failed: {str(e)}")
+        return JSONResponse(
+            content={"success": False, "message": str(e)},
+            status_code=500
+        )
+
+def _read_and_encode_preview(cache_path: str) -> str:
+    """Read preview image from disk and encode as base64.
+    
+    Combines file I/O and base64 encoding in a single function
+    to be run in executor, reducing context switches.
+    """
+    with open(cache_path, 'rb') as f:
+        image_data = f.read()
+    return base64.b64encode(image_data).decode('utf-8')
+
+@app.post("/preview_thr_batch")
+async def preview_thr_batch(request: dict):
+    start = time.time()
+    if not request.get("file_names"):
+        logger.warning("Batch preview request received without filenames")
+        raise HTTPException(status_code=400, detail="No file names provided")
+
+    file_names = request["file_names"]
+    if not isinstance(file_names, list):
+        raise HTTPException(status_code=400, detail="file_names must be a list")
+
+    headers = {
+        "Cache-Control": "public, max-age=3600",  # Cache for 1 hour
+        "Content-Type": "application/json"
+    }
+
+    async def process_single_file(file_name):
+        """Process a single file and return its preview data."""
+        # Check in-memory cache first (for current and next playing patterns)
+        normalized_for_cache = normalize_file_path(file_name)
+        if state._current_preview and state._current_preview[0] == normalized_for_cache:
+            logger.debug(f"Using cached preview for current: {file_name}")
+            return file_name, state._current_preview[1]
+        if state._next_preview and state._next_preview[0] == normalized_for_cache:
+            logger.debug(f"Using cached preview for next: {file_name}")
+            return file_name, state._next_preview[1]
+
+        # Acquire semaphore to limit concurrent processing
+        async with get_preview_semaphore():
+            t1 = time.time()
+            try:
+                # Normalize file path for cross-platform compatibility
+                normalized_file_name = normalize_file_path(file_name)
+                pattern_file_path = os.path.join(pattern_manager.THETA_RHO_DIR, normalized_file_name)
+
+                # Check file existence asynchronously
+                exists = await asyncio.to_thread(os.path.exists, pattern_file_path)
+                if not exists:
+                    logger.warning(f"Pattern file not found: {pattern_file_path}")
+                    return file_name, {"error": "Pattern file not found"}
+
+                cache_path = get_cache_path(normalized_file_name)
+
+                # Check cache existence asynchronously
+                cache_exists = await asyncio.to_thread(os.path.exists, cache_path)
+                if not cache_exists:
+                    logger.info(f"Cache miss for {file_name}. Generating preview...")
+                    success = await generate_image_preview(normalized_file_name)
+                    cache_exists_after = await asyncio.to_thread(os.path.exists, cache_path)
+                    if not success or not cache_exists_after:
+                        logger.error(f"Failed to generate or find preview for {file_name}")
+                        return file_name, {"error": "Failed to generate preview"}
+
+                metadata = get_pattern_metadata(normalized_file_name)
+                if metadata:
+                    first_coord_obj = metadata.get('first_coordinate')
+                    last_coord_obj = metadata.get('last_coordinate')
+                else:
+                    logger.debug(f"Metadata cache miss for {file_name}, parsing file")
+                    # Use thread pool to avoid memory pressure on resource-constrained devices
+                    coordinates = await asyncio.to_thread(parse_theta_rho_file, pattern_file_path)
+                    first_coord = coordinates[0] if coordinates else None
+                    last_coord = coordinates[-1] if coordinates else None
+                    first_coord_obj = {"x": first_coord[0], "y": first_coord[1]} if first_coord else None
+                    last_coord_obj = {"x": last_coord[0], "y": last_coord[1]} if last_coord else None
+
+                # Read image file and encode in executor to avoid blocking event loop
+                loop = asyncio.get_running_loop()
+                image_b64 = await loop.run_in_executor(None, _read_and_encode_preview, cache_path)
+                result = {
+                    "image_data": f"data:image/webp;base64,{image_b64}",
+                    "first_coordinate": first_coord_obj,
+                    "last_coordinate": last_coord_obj
+                }
+
+                # Cache preview for current/next pattern to speed up subsequent requests
+                current_file = state.current_playing_file
+                if current_file:
+                    current_normalized = normalize_file_path(current_file)
+                    if normalized_file_name == current_normalized:
+                        state._current_preview = (normalized_file_name, result)
+                        logger.debug(f"Cached preview for current: {file_name}")
+                    elif state.current_playlist:
+                        # Check if this is the next pattern in playlist
+                        playlist = state.current_playlist
+                        status_pl = execution.get_cached_status().get("playlist") or {}
+                        idx = status_pl.get("current_index")
+                        if idx is not None and idx + 1 < len(playlist):
+                            next_file = normalize_file_path(playlist[idx + 1])
+                            if normalized_file_name == next_file:
+                                state._next_preview = (normalized_file_name, result)
+                                logger.debug(f"Cached preview for next: {file_name}")
+
+                logger.debug(f"Processed {file_name} in {time.time() - t1:.2f}s")
+                return file_name, result
+            except Exception as e:
+                logger.error(f"Error processing {file_name}: {str(e)}")
+                return file_name, {"error": str(e)}
+
+    # Process all files concurrently
+    tasks = [process_single_file(file_name) for file_name in file_names]
+    file_results = await asyncio.gather(*tasks)
+
+    # Convert results to dictionary
+    results = dict(file_results)
+
+    logger.debug(f"Total batch processing time: {time.time() - start:.2f}s for {len(file_names)} files")
+    return JSONResponse(content=results, headers=headers)
+
+@app.get("/playlists")
+async def playlists_page(request: Request):
+    return get_redirect_response(request)
+
+@app.get("/image2sand")
+async def image2sand_page(request: Request):
+    return get_redirect_response(request)
+
+@app.get("/led")
+async def led_control_page(request: Request):
+    return get_redirect_response(request)
+
+# DW LED control endpoints
+@app.get("/api/dw_leds/status")
+async def dw_leds_status():
+    """Get DW LED controller status"""
+    if not state.led_controller or state.led_provider not in ("dw_leds", "board"):
+        return {"connected": False, "message": "DW LEDs not configured"}
+
+    try:
+        return state.led_controller.check_status()
+    except Exception as e:
+        logger.error(f"Failed to check DW LED status: {str(e)}")
+        return {"connected": False, "message": str(e)}
+
+@app.post("/api/dw_leds/power")
+async def dw_leds_power(request: dict):
+    """Control DW LED power (0=off, 1=on, 2=toggle)"""
+    if not state.led_controller or state.led_provider not in ("dw_leds", "board"):
+        raise HTTPException(status_code=400, detail="DW LEDs not configured")
+
+    state_value = request.get("state", 1)
+    if state_value not in [0, 1, 2]:
+        raise HTTPException(status_code=400, detail="State must be 0 (off), 1 (on), or 2 (toggle)")
+
+    try:
+        result = state.led_controller.set_power(state_value)
+
+        # Reset idle timeout when LEDs are manually powered on (only if idle timeout is enabled)
+        # This prevents idle timeout from immediately turning them back off
+        if state_value in [1, 2] and state.dw_led_idle_timeout_enabled:  # Power on or toggle
+            state.dw_led_last_activity_time = time.time()
+            logger.debug("LED activity time reset due to manual power on")
+
+        return result
+    except Exception as e:
+        logger.error(f"Failed to set DW LED power: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/api/dw_leds/brightness")
+async def dw_leds_brightness(request: dict):
+    """Set DW LED brightness (0-100)"""
+    if not state.led_controller or state.led_provider not in ("dw_leds", "board"):
+        raise HTTPException(status_code=400, detail="DW LEDs not configured")
+
+    value = request.get("value", 50)
+    if not 0 <= value <= 100:
+        raise HTTPException(status_code=400, detail="Brightness must be between 0 and 100")
+
+    try:
+        controller = state.led_controller.get_controller()
+        # The value persists where it lives (board NVS / WLED) — no host state.
+        return controller.set_brightness(value)
+    except Exception as e:
+        logger.error(f"Failed to set LED brightness: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/api/dw_leds/color")
+async def dw_leds_color(request: dict):
+    """Set solid color (manual UI control - always powers on LEDs)"""
+    if not state.led_controller or state.led_provider not in ("dw_leds", "board"):
+        raise HTTPException(status_code=400, detail="DW LEDs not configured")
+
+    # Accept both formats: {"r": 255, "g": 0, "b": 0} or {"color": [255, 0, 0]}
+    if "color" in request:
+        color = request["color"]
+        if not isinstance(color, list) or len(color) != 3:
+            raise HTTPException(status_code=400, detail="Color must be [R, G, B] array")
+        r, g, b = color[0], color[1], color[2]
+    elif "r" in request and "g" in request and "b" in request:
+        r = request["r"]
+        g = request["g"]
+        b = request["b"]
+    else:
+        raise HTTPException(status_code=400, detail="Color must include r, g, b fields or color array")
+
+    try:
+        controller = state.led_controller.get_controller()
+        # Power on LEDs when user manually sets color via UI
+        controller.set_power(1)
+        # Reset idle timeout for manual interaction (only if idle timeout is enabled)
+        if state.dw_led_idle_timeout_enabled:
+            state.dw_led_last_activity_time = time.time()
+        return controller.set_color(r, g, b)
+    except Exception as e:
+        logger.error(f"Failed to set DW LED color: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/api/dw_leds/colors")
+async def dw_leds_colors(request: dict):
+    """Set effect colors (color1, color2, color3) - manual UI control - always powers on LEDs"""
+    if not state.led_controller or state.led_provider not in ("dw_leds", "board"):
+        raise HTTPException(status_code=400, detail="DW LEDs not configured")
+
+    # Parse colors from request
+    color1 = None
+    color2 = None
+    color3 = None
+
+    if "color1" in request:
+        c = request["color1"]
+        if isinstance(c, list) and len(c) == 3:
+            color1 = tuple(c)
+        else:
+            raise HTTPException(status_code=400, detail="color1 must be [R, G, B] array")
+
+    if "color2" in request:
+        c = request["color2"]
+        if isinstance(c, list) and len(c) == 3:
+            color2 = tuple(c)
+        else:
+            raise HTTPException(status_code=400, detail="color2 must be [R, G, B] array")
+
+    if "color3" in request:
+        c = request["color3"]
+        if isinstance(c, list) and len(c) == 3:
+            color3 = tuple(c)
+        else:
+            raise HTTPException(status_code=400, detail="color3 must be [R, G, B] array")
+
+    if not any([color1, color2, color3]):
+        raise HTTPException(status_code=400, detail="Must provide at least one color")
+
+    try:
+        controller = state.led_controller.get_controller()
+        # Power on LEDs when user manually sets colors via UI
+        controller.set_power(1)
+        # Reset idle timeout for manual interaction (only if idle timeout is enabled)
+        if state.dw_led_idle_timeout_enabled:
+            state.dw_led_last_activity_time = time.time()
+        return controller.set_colors(color1=color1, color2=color2, color3=color3)
+    except Exception as e:
+        logger.error(f"Failed to set DW LED colors: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.get("/api/dw_leds/effects")
+async def dw_leds_effects():
+    """Get list of available effects"""
+    if not state.led_controller or state.led_provider not in ("dw_leds", "board"):
+        raise HTTPException(status_code=400, detail="DW LEDs not configured")
+
+    try:
+        controller = state.led_controller.get_controller()
+        effects = controller.get_effects()
+        # Convert tuples to lists for JSON serialization
+        effects_list = [[eid, name] for eid, name in effects]
+        result = {"success": True, "effects": effects_list}
+        # Board provider: also expose the firmware effect *names* (id -> name),
+        # which the ball tracker's background sub-effect picker needs.
+        if state.led_provider == "board":
+            from modules.led.board_led_controller import BOARD_EFFECTS
+            result["names"] = [[i, name] for i, (name, _label) in enumerate(BOARD_EFFECTS)]
+        return result
+    except Exception as e:
+        logger.error(f"Failed to get DW LED effects: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.get("/api/dw_leds/palettes")
+async def dw_leds_palettes():
+    """Get list of available palettes"""
+    if not state.led_controller or state.led_provider not in ("dw_leds", "board"):
+        raise HTTPException(status_code=400, detail="DW LEDs not configured")
+
+    try:
+        controller = state.led_controller.get_controller()
+        palettes = controller.get_palettes()
+        # Convert tuples to lists for JSON serialization
+        palettes_list = [[pid, name] for pid, name in palettes]
+        return {
+            "success": True,
+            "palettes": palettes_list
+        }
+    except Exception as e:
+        logger.error(f"Failed to get DW LED palettes: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/api/dw_leds/effect")
+async def dw_leds_effect(request: dict):
+    """Set effect by ID (manual UI control - always powers on LEDs)"""
+    if not state.led_controller or state.led_provider not in ("dw_leds", "board"):
+        raise HTTPException(status_code=400, detail="DW LEDs not configured")
+
+    effect_id = request.get("effect_id", 0)
+    speed = request.get("speed")
+    intensity = request.get("intensity")
+
+    try:
+        controller = state.led_controller.get_controller()
+        # Power on LEDs when user manually sets effect via UI
+        controller.set_power(1)
+        # Reset idle timeout for manual interaction (only if idle timeout is enabled)
+        if state.dw_led_idle_timeout_enabled:
+            state.dw_led_last_activity_time = time.time()
+        return controller.set_effect(effect_id, speed=speed, intensity=intensity)
+    except Exception as e:
+        logger.error(f"Failed to set DW LED effect: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/api/dw_leds/palette")
+async def dw_leds_palette(request: dict):
+    """Set palette by ID (manual UI control - always powers on LEDs)"""
+    if not state.led_controller or state.led_provider not in ("dw_leds", "board"):
+        raise HTTPException(status_code=400, detail="DW LEDs not configured")
+
+    palette_id = request.get("palette_id", 0)
+
+    try:
+        controller = state.led_controller.get_controller()
+        # Power on LEDs when user manually sets palette via UI
+        controller.set_power(1)
+        # Reset idle timeout for manual interaction (only if idle timeout is enabled)
+        if state.dw_led_idle_timeout_enabled:
+            state.dw_led_last_activity_time = time.time()
+        return controller.set_palette(palette_id)
+    except Exception as e:
+        logger.error(f"Failed to set DW LED palette: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/api/dw_leds/speed")
+async def dw_leds_speed(request: dict):
+    """Set effect speed (0-255)"""
+    if not state.led_controller or state.led_provider not in ("dw_leds", "board"):
+        raise HTTPException(status_code=400, detail="DW LEDs not configured")
+
+    value = request.get("speed", 128)
+    if not 0 <= value <= 255:
+        raise HTTPException(status_code=400, detail="Speed must be between 0 and 255")
+
+    try:
+        controller = state.led_controller.get_controller()
+        return controller.set_speed(value)
+    except Exception as e:
+        logger.error(f"Failed to set LED speed: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/api/dw_leds/ball")
+async def dw_leds_ball(request: dict):
+    """Tune the firmware-native 'ball' tracker effect (board provider only).
+
+    Accepts any of: fgbright, bgbright, size, align (ints), direction ('cw'|'ccw'),
+    bg (background sub-effect name / 'static' / 'off'), color, color2 (RRGGBB hex).
+    Applied live via /sand_led; persisted to the board's NVS at idle.
+    """
+    if not state.led_controller or state.led_provider != "board":
+        raise HTTPException(status_code=400, detail="The ball tracker requires the Table LEDs provider")
+
+    try:
+        controller = state.led_controller.get_controller()
+        return await asyncio.to_thread(controller.set_ball, **request)
+    except Exception as e:
+        logger.error(f"Failed to set ball effect: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/api/dw_leds/intensity")
+async def dw_leds_intensity(request: dict):
+    """Set effect intensity (0-255)"""
+    if not state.led_controller or state.led_provider not in ("dw_leds", "board"):
+        raise HTTPException(status_code=400, detail="DW LEDs not configured")
+
+    value = request.get("intensity", 128)
+    if not 0 <= value <= 255:
+        raise HTTPException(status_code=400, detail="Intensity must be between 0 and 255")
+
+    try:
+        controller = state.led_controller.get_controller()
+        return controller.set_intensity(value)
+    except Exception as e:
+        logger.error(f"Failed to set LED intensity: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/api/dw_leds/save_effect_settings")
+async def dw_leds_save_effect_settings(request: dict):
+    """Save current LED settings as idle or playing effect"""
+    effect_type = request.get("type")  # 'idle' or 'playing'
+
+    settings = {
+        "effect_id": request.get("effect_id"),
+        "palette_id": request.get("palette_id"),
+        "speed": request.get("speed"),
+        "intensity": request.get("intensity"),
+        "color1": request.get("color1"),
+        "color2": request.get("color2"),
+        "color3": request.get("color3")
+    }
+
+    if effect_type not in ("idle", "playing"):
+        raise HTTPException(status_code=400, detail="Invalid effect type. Must be 'idle' or 'playing'")
+
+    # Board provider: the firmware switches effects itself — persist the choice
+    # as $LED/IdleEffect / $LED/RunEffect on the board instead of host state.
+    if state.led_provider == "board":
+        from modules.led.board_led_controller import effect_name_for_id
+        name = effect_name_for_id(int(settings.get("effect_id") or 0)) or "none"
+        controller = state.led_controller.get_controller() if state.led_controller else None
+        if not controller:
+            raise HTTPException(status_code=409, detail="Table LEDs not configured")
+        ok = await asyncio.to_thread(
+            controller.set_idle_effect if effect_type == "idle" else controller.set_run_effect, name
+        )
+        if not ok:
+            raise HTTPException(status_code=502, detail="Table rejected the setting (is it idle?)")
+        logger.info(f"Board LED {effect_type} effect set to {name}")
+        return {"success": True, "type": effect_type, "settings": settings}
+
+    raise HTTPException(status_code=400, detail="Effect automation requires the Table LEDs provider")
+
+@app.post("/api/dw_leds/clear_effect_settings")
+async def dw_leds_clear_effect_settings(request: dict):
+    """Clear idle or playing effect settings"""
+    effect_type = request.get("type")  # 'idle' or 'playing'
+
+    if effect_type not in ("idle", "playing"):
+        raise HTTPException(status_code=400, detail="Invalid effect type. Must be 'idle' or 'playing'")
+
+    if state.led_provider == "board":
+        controller = state.led_controller.get_controller() if state.led_controller else None
+        if not controller:
+            raise HTTPException(status_code=409, detail="Table LEDs not configured")
+        ok = await asyncio.to_thread(
+            controller.set_idle_effect if effect_type == "idle" else controller.set_run_effect, "none"
+        )
+        if not ok:
+            raise HTTPException(status_code=502, detail="Table rejected the setting (is it idle?)")
+        logger.info(f"Board LED {effect_type} effect disabled")
+        return {"success": True, "type": effect_type}
+
+    raise HTTPException(status_code=400, detail="Effect automation requires the Table LEDs provider")
+
+@app.get("/api/dw_leds/get_effect_settings")
+async def dw_leds_get_effect_settings():
+    """Get saved idle and playing effect settings"""
+    # Board provider: the choices live on the board ($LED/IdleEffect / RunEffect).
+    if state.led_provider == "board" and state.led_controller:
+        from modules.led.board_led_controller import effect_id_for_name
+        status = await asyncio.to_thread(state.led_controller.check_status)
+        if status.get("connected"):
+            def as_settings(name):
+                if not name or name == "none":
+                    return None
+                return {"effect_id": effect_id_for_name(name), "palette_id": None,
+                        "speed": None, "intensity": None,
+                        "color1": None, "color2": None, "color3": None}
+            return {
+                "idle_effect": as_settings(status.get("idle_effect")),
+                "playing_effect": as_settings(status.get("run_effect")),
+            }
+    return {"idle_effect": None, "playing_effect": None}
+
+@app.post("/api/dw_leds/idle_timeout")
+async def dw_leds_set_idle_timeout(request: dict):
+    """Configure LED idle timeout settings"""
+    enabled = request.get("enabled", False)
+    minutes = request.get("minutes", 30)
+
+    # Validate minutes (between 1 and 1440 - 24 hours)
+    if minutes < 1 or minutes > 1440:
+        raise HTTPException(status_code=400, detail="Timeout must be between 1 and 1440 minutes")
+
+    state.dw_led_idle_timeout_enabled = enabled
+    state.dw_led_idle_timeout_minutes = minutes
+
+    # Reset activity time when settings change
+    import time
+    state.dw_led_last_activity_time = time.time()
+
+    state.save()
+    logger.info(f"DW LED idle timeout configured: enabled={enabled}, minutes={minutes}")
+
+    return {
+        "success": True,
+        "enabled": enabled,
+        "minutes": minutes
+    }
+
+@app.get("/api/dw_leds/idle_timeout")
+async def dw_leds_get_idle_timeout():
+    """Get LED idle timeout settings"""
+    import time
+
+    # Calculate remaining time if timeout is active
+    remaining_minutes = None
+    if state.dw_led_idle_timeout_enabled and state.dw_led_last_activity_time:
+        elapsed_seconds = time.time() - state.dw_led_last_activity_time
+        timeout_seconds = state.dw_led_idle_timeout_minutes * 60
+        remaining_seconds = max(0, timeout_seconds - elapsed_seconds)
+        remaining_minutes = round(remaining_seconds / 60, 1)
+
+    return {
+        "enabled": state.dw_led_idle_timeout_enabled,
+        "minutes": state.dw_led_idle_timeout_minutes,
+        "remaining_minutes": remaining_minutes
+    }
+
+# ── Screen (LCD backlight) control endpoints ──────────────────────
+
+@app.get("/api/screen/status")
+async def screen_status():
+    """Get screen controller status."""
+    if not state.screen_controller:
+        return {"available": False, "message": "Screen controller not initialized"}
+    return state.screen_controller.get_status()
+
+@app.post("/api/screen/power")
+async def screen_power(request: dict):
+    """Turn screen on/off. Body: {"on": true/false}"""
+    if not state.screen_controller or not state.screen_controller.available:
+        raise HTTPException(status_code=400, detail="Screen control not available")
+
+    on = request.get("on", True)
+    result = state.screen_controller.set_power(on)
+    if not result.get("success"):
+        raise HTTPException(status_code=500, detail=result.get("message", "Unknown error"))
+
+    # Publish updated state to MQTT
+    if state.mqtt_handler and state.mqtt_handler.is_enabled:
+        state.mqtt_handler._publish_screen_state()
+
+    return result
+
+@app.post("/api/screen/brightness")
+async def screen_brightness(request: dict):
+    """Set screen brightness. Body: {"value": 0-max_brightness}"""
+    if not state.screen_controller or not state.screen_controller.available:
+        raise HTTPException(status_code=400, detail="Screen control not available")
+
+    value = request.get("value", 128)
+    result = state.screen_controller.set_brightness(value)
+    if not result.get("success"):
+        raise HTTPException(status_code=500, detail=result.get("message", "Unknown error"))
+
+    # Publish updated state to MQTT
+    if state.mqtt_handler and state.mqtt_handler.is_enabled:
+        state.mqtt_handler._publish_screen_state()
+
+    return result
+
+@app.get("/table_control")
+async def table_control_page(request: Request):
+    return get_redirect_response(request)
+
+@app.get("/cache-progress")
+async def get_cache_progress_endpoint():
+    """Get the current cache generation progress."""
+    from modules.core.cache_manager import get_cache_progress
+    return get_cache_progress()
+
+@app.post("/rebuild_cache")
+async def rebuild_cache_endpoint():
+    """Trigger a rebuild of the pattern cache."""
+    try:
+        from modules.core.cache_manager import rebuild_cache
+        await rebuild_cache()
+        return {"success": True, "message": "Cache rebuild completed successfully"}
+    except Exception as e:
+        logger.error(f"Failed to rebuild cache: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+def signal_handler(signum, frame):
+    """Handle shutdown signals gracefully."""
+    logger.info("Received shutdown signal, cleaning up...")
+    try:
+        # Turn off all LEDs on shutdown
+        if state.led_controller:
+            state.led_controller.set_power(0)
+
+        state.save()
+        logger.info("Cleanup completed")
+    except Exception as e:
+        logger.error(f"Error during cleanup: {str(e)}")
+    finally:
+        logger.info("Exiting application...")
+        # Use os._exit after cleanup is complete to avoid async stack tracebacks
+        # This is safe because we've already: shut down process pool, stopped motion controller, saved state
+        os._exit(0)
+
+@app.get("/api/version")
+async def get_version_info(force_refresh: bool = False):
+    """Get current and latest version information
+
+    Args:
+        force_refresh: If true, bypass cache and fetch fresh data from GitHub
+    """
+    try:
+        version_info = await version_manager.get_version_info(force_refresh=force_refresh)
+        return JSONResponse(content=version_info)
+    except Exception as e:
+        logger.error(f"Error getting version info: {e}")
+        return JSONResponse(
+            content={
+                "current": await version_manager.get_current_version(),
+                "latest": await version_manager.get_current_version(),
+                "update_available": False,
+                "error": "Unable to check for updates"
+            },
+            status_code=200
+        )
+
+@app.post("/api/update")
+async def trigger_update():
+    """Trigger software update by running `dw update` as a detached process.
+
+    The `dw` CLI handles pulling code and restarting the service.
+    We fire-and-forget so the response returns immediately before the
+    service goes down for restart.
+    """
+    try:
+        logger.info("Update triggered via API")
+        dw_path = '/usr/local/bin/dw'
+        log_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'update.log')
+        logger.info(f"Running: {dw_path} update (log: {log_file})")
+        with open(log_file, 'w') as f:
+            subprocess.Popen(
+                [dw_path, 'update'],
+                stdout=f,
+                stderr=subprocess.STDOUT,
+                start_new_session=True,
+                cwd=os.path.dirname(os.path.abspath(__file__))
+            )
+        return JSONResponse(content={
+            "success": True,
+            "message": "Update started"
+        })
+    except Exception as e:
+        logger.error(f"Error triggering update: {e}")
+        return JSONResponse(
+            content={"success": False, "message": f"Failed to trigger update: {str(e)}"},
+            status_code=500
+        )
+
+@app.post("/api/system/shutdown")
+async def shutdown_system():
+    """Shutdown the system"""
+    try:
+        logger.warning("Shutdown initiated via API")
+
+        # Schedule shutdown command after a short delay to allow response to be sent
+        def delayed_shutdown():
+            time.sleep(2)  # Give time for response to be sent
+            try:
+                subprocess.run(["/usr/bin/sudo", "/usr/bin/systemctl", "poweroff"], check=True)
+                logger.info("System shutdown command executed successfully")
+            except FileNotFoundError:
+                logger.error("sudo or systemctl command not found - ensure systemd is available")
+            except Exception as e:
+                logger.error(f"Error executing host shutdown command: {e}")
+
+        import threading
+        shutdown_thread = threading.Thread(target=delayed_shutdown)
+        shutdown_thread.start()
+
+        return {"success": True, "message": "System shutdown initiated"}
+    except Exception as e:
+        logger.error(f"Error initiating shutdown: {e}")
+        return JSONResponse(
+            content={"success": False, "message": str(e)},
+            status_code=500
+        )
+
+@app.post("/api/system/restart")
+async def restart_system():
+    """Restart the Dune Weaver service via systemctl."""
+    try:
+        logger.warning("Restart initiated via API")
+
+        # Schedule restart command after a short delay to allow response to be sent
+        def delayed_restart():
+            time.sleep(2)  # Give time for response to be sent
+            try:
+                subprocess.run(["/usr/bin/sudo", "/usr/bin/systemctl", "restart", "dune-weaver"], check=True)
+                logger.info("Service restart command executed successfully")
+            except FileNotFoundError:
+                logger.error("sudo or systemctl command not found - ensure systemd is available")
+            except Exception as e:
+                logger.error(f"Error executing service restart: {e}")
+
+        import threading
+        restart_thread = threading.Thread(target=delayed_restart)
+        restart_thread.start()
+
+        return {"success": True, "message": "System restart initiated"}
+    except Exception as e:
+        logger.error(f"Error initiating restart: {e}")
+        return JSONResponse(
+            content={"success": False, "message": str(e)},
+            status_code=500
+        )
+
+###############################################################################
+# FluidNC Config Endpoints
+###############################################################################
+
+def entrypoint():
+    import uvicorn
+    logger.info("Starting FastAPI server on port 8080...")
+    uvicorn.run(app, host="0.0.0.0", port=8080, workers=1)  # Set workers to 1 to avoid multiple signal handlers
+
+if __name__ == "__main__":
     entrypoint()

+ 19 - 51
modules/connection/connection_manager.py

@@ -5,10 +5,10 @@ The board firmware now owns kinematics, `.thr` playback, progress and homing
 (contract: the firmware repo's API.md). This module is the thin seam the backend
 uses to talk to it: it builds a FluidNCClient, exposes the same public functions
 the rest of the app already calls (connect_device, device_init, home,
-check_idle_async, is_machine_idle, get_machine_position, update_machine_position,
-perform_soft_reset, restart_connection, list_serial_ports), and keeps the LED
-hooks intact. The old serial/websocket GRBL transport, coordinate streaming, and
-the $H/$J homing handshake are gone — the firmware does all of that itself.
+check_idle_async, is_machine_idle, update_machine_position, perform_soft_reset,
+restart_connection, list_serial_ports), and keeps the LED hooks intact. The old
+serial/websocket GRBL transport, coordinate streaming, and the $H/$J homing
+handshake are gone — the firmware does all of that itself.
 """
 
 import os
@@ -16,6 +16,7 @@ import time
 import math
 import logging
 import asyncio
+import threading
 
 from modules.core.state import state
 from modules.connection.fluidnc_client import FluidNCClient
@@ -92,12 +93,6 @@ def poll_status_once() -> dict | None:
         return None
 
 
-def get_status_response() -> str:
-    """Back-compat shim: return the board's machine state as a GRBL-like token."""
-    st = poll_status_once()
-    return st.get("state", "") if st else ""
-
-
 ###############################################################################
 # Connection lifecycle
 ###############################################################################
@@ -156,10 +151,18 @@ def device_init(homing=True):
         return False
 
     state.firmware_type = "fluidnc"
-    state.firmware_version = state.firmware_version or "fluidnc"
+    state.firmware_version = st.get("fw") or state.firmware_version or "fluidnc"
     apply_status(st)
     _sync_homing_settings()
 
+    # Reconcile board-owned settings (clock, Still Sands, auto-home cadence) and
+    # mirror playlists in the background — mirroring can be slow and must never
+    # delay connect/homing.
+    from modules.core import board_settings
+    threading.Thread(
+        target=board_settings.sync_on_connect, args=(state.conn,), daemon=True
+    ).start()
+
     if homing and state.home_on_connect:
         home()
 
@@ -171,19 +174,10 @@ def connect_device(homing=True):
     # Initialize LED interface based on configured provider (unchanged from before).
     if state.led_provider == "wled" and state.wled_ip:
         state.led_controller = LEDInterface(provider="wled", ip_address=state.wled_ip)
-    elif state.led_provider == "dw_leds":
-        # DW LEDs are initialized at startup in main.py; only set up here on reconnect.
+    elif state.led_provider == "board":
         if not state.led_controller or not state.led_controller.is_configured:
-            state.led_controller = LEDInterface(
-                provider="dw_leds",
-                num_leds=state.dw_led_num_leds,
-                gpio_pin=state.dw_led_gpio_pin,
-                pixel_order=state.dw_led_pixel_order,
-                brightness=state.dw_led_brightness / 100.0,
-                speed=state.dw_led_speed,
-                intensity=state.dw_led_intensity,
-            )
-    elif state.led_provider == "none" or not state.led_provider:
+            state.led_controller = LEDInterface(provider="board")
+    else:
         state.led_controller = None
 
     if state.led_controller:
@@ -204,25 +198,10 @@ def connect_device(homing=True):
     if state.led_controller:
         logger.info("Showing LED connected effect (green flash)")
         state.led_controller.effect_connected()
-        logger.info(f"Setting LED to idle effect: {state.dw_led_idle_effect}")
-        state.led_controller.effect_idle(state.dw_led_idle_effect)
+        state.led_controller.effect_idle(None)
         _start_idle_led_timeout()
 
 
-def check_and_unlock_alarm():
-    """
-    Compatibility shim. The firmware manages its own alarm/unlock; if the board
-    reports an Alarm state we ask it to home (which clears it). Returns True when
-    the board is reachable.
-    """
-    st = poll_status_once()
-    if st is None:
-        return False
-    if st.get("state") == "Alarm":
-        logger.info("Board in Alarm state; a home will be required to clear it")
-    return True
-
-
 ###############################################################################
 # Homing
 ###############################################################################
@@ -282,12 +261,9 @@ def home(timeout=120):
 ###############################################################################
 
 async def check_idle_async(timeout: float = 30.0):
-    """Wait until the board reports Idle (or stop requested / timeout)."""
+    """Wait until the board reports Idle (or timeout)."""
     start_time = asyncio.get_event_loop().time()
     while True:
-        if state.stop_requested:
-            logger.debug("Stop requested during idle check, exiting early")
-            return False
         if asyncio.get_event_loop().time() - start_time > timeout:
             logger.warning(f"Timeout ({timeout}s) waiting for board idle state")
             return False
@@ -303,14 +279,6 @@ def is_machine_idle() -> bool:
     return bool(st and st.get("state") == "Idle")
 
 
-def get_machine_position(timeout=5):
-    """
-    Position is owned by the board now (reported as theta/rho in /sand_status, not
-    cartesian MPos). Return the last-known machine coordinates for persistence.
-    """
-    return state.machine_x, state.machine_y
-
-
 async def update_machine_position():
     """Refresh theta/rho from the board and persist state."""
     if state.conn and state.conn.is_connected():

+ 27 - 1
modules/connection/fluidnc_client.py

@@ -25,7 +25,7 @@ logger = logging.getLogger(__name__)
 class FluidNCClient:
     """Stateless HTTP handle to one FluidNC sand-table board."""
 
-    def __init__(self, base_url: str, timeout: float = 5.0):
+    def __init__(self, base_url: str, timeout: float = 10.0):
         # base_url like "http://192.168.68.160"
         self.base_url = base_url.rstrip("/")
         self.timeout = timeout
@@ -86,12 +86,38 @@ class FluidNCClient:
         except Exception:
             return False
 
+    # -- clock ----------------------------------------------------------------
+
+    def get_time(self) -> dict:
+        """The board's wall clock: {epoch, synced, local, tz}."""
+        return self._get("/sand_time").json()
+
+    def set_time(self, epoch: int | None = None, tz: str | None = None) -> dict:
+        """Push the wall clock (unix epoch) and/or a POSIX timezone to the board."""
+        params: dict = {}
+        if epoch is not None:
+            params["epoch"] = int(epoch)
+        if tz is not None:
+            params["tz"] = tz
+        return self._get("/sand_time", params=params).json()
+
     # -- commands / actions --------------------------------------------------
 
     def run_command(self, plain: str) -> str:
         """Fire a FluidNC command via the /command gateway (fire-and-forget)."""
         return self._get("/command", params={"plain": plain}).text
 
+    def set_setting(self, key: str, value) -> str:
+        """Write one NVS-persisted board setting, e.g. set_setting('Playlist/Autostart', 'evening').
+
+        The command response contains 'error' on rejection (e.g. idle-gated
+        settings while running); raise so callers can surface it.
+        """
+        resp = self.run_command(f"${key}={value}")
+        if "error" in resp.lower():
+            raise RuntimeError(f"Board rejected ${key}={value}: {resp.strip()}")
+        return resp
+
     def run_pattern(self, sd_path: str, clear: str | None = None) -> None:
         """
         Start a pattern. With a clear mode, uses $Sand/Run (which sequences

+ 0 - 337
modules/connection/fluidnc_config.py

@@ -1,337 +0,0 @@
-"""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)

+ 390 - 0
modules/core/board_settings.py

@@ -0,0 +1,390 @@
+"""
+Board-owned settings sync — the host side of the "board NVS is canonical" rule.
+
+The FluidNC firmware persists table behavior settings in NVS ($Playlist/*,
+$Sands/*, $Sand/*) and the native mobile apps edit them directly on the board.
+This module keeps the web backend in agreement:
+
+  - Auto-play on boot ($Playlist/Autostart*) lives ONLY on the board — it fires
+    when the *table* powers on, whether or not this backend is running. The
+    backend proxies reads/writes for the web UI and mirrors host playlists to
+    the board SD so autostart has something to run.
+  - Still Sands quiet hours ($Sands/*) are stored on the board, but the firmware
+    only enforces them for its own playlist sequencing — an explicit $Sand/Run
+    (how this backend plays each pattern) deliberately bypasses them. So the
+    host keeps enforcing quiet hours itself via state.scheduled_pause_* in
+    pattern_manager; this module pushes UI edits to the board and adopts board
+    values (mobile app edits) back into host state.
+  - The board clock must be set for quiet hours / autostart schedules; the host
+    pushes its epoch + POSIX timezone on connect and whenever the tz changes.
+
+All pushes are best-effort: the board being unreachable never blocks a host-side
+settings save (host enforcement still works without the board's copy).
+"""
+
+import logging
+import threading
+import time
+
+from modules.core.state import state
+
+logger = logging.getLogger(__name__)
+
+# Host slot day names (state.scheduled_pause_time_slots) <-> firmware 3-letter codes.
+_DAY_TO_BOARD = {
+    "sunday": "sun", "monday": "mon", "tuesday": "tue", "wednesday": "wed",
+    "thursday": "thu", "friday": "fri", "saturday": "sat",
+}
+_BOARD_TO_DAY = {v: k for k, v in _DAY_TO_BOARD.items()}
+_BOARD_DAY_ORDER = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"]
+
+CLEAR_MODES = ("none", "adaptive", "in", "out", "sideway", "random")
+
+
+def _is_on(value) -> bool:
+    return str(value or "").upper() in ("ON", "1", "TRUE")
+
+
+# ---------------------------------------------------------------------------
+# Still Sands slot conversion: host dicts <-> "$Sands/Slots" spec string
+# ---------------------------------------------------------------------------
+
+def slots_to_board(time_slots: list) -> str:
+    """Host slot dicts -> 'HH:MM-HH:MM@days,...' ($Sands/Slots syntax)."""
+    parts = []
+    for slot in time_slots or []:
+        start = slot.get("start_time")
+        end = slot.get("end_time")
+        if not start or not end:
+            continue
+        days = slot.get("days", "daily")
+        if days == "custom":
+            codes = sorted(
+                {_DAY_TO_BOARD[d] for d in slot.get("custom_days", []) if d in _DAY_TO_BOARD},
+                key=_BOARD_DAY_ORDER.index,
+            )
+            days = "+".join(codes) if codes else "daily"
+        parts.append(f"{start}-{end}@{days}")
+    return ",".join(parts)
+
+
+def _normalize_time(value: str) -> str | None:
+    """'9:5' -> '09:05'; None when not a valid HH:MM."""
+    hours, sep, minutes = value.strip().partition(":")
+    if not sep or not hours.isdigit() or not minutes.isdigit():
+        return None
+    h, m = int(hours), int(minutes)
+    if h > 23 or m > 59:
+        return None
+    return f"{h:02d}:{m:02d}"
+
+
+def board_to_slots(spec: str) -> list:
+    """'$Sands/Slots' spec string -> host slot dicts (invalid entries dropped)."""
+    slots = []
+    for part in (spec or "").split(","):
+        part = part.strip()
+        if not part:
+            continue
+        times, _, days_spec = part.partition("@")
+        start, dash, end = times.strip().partition("-")
+        start, end = _normalize_time(start), _normalize_time(end or "")
+        if not dash or not start or not end:
+            continue
+        days_spec = (days_spec or "daily").strip().lower()
+        if days_spec in ("", "daily", "weekdays", "weekends"):
+            days, custom_days = (days_spec or "daily"), []
+        else:
+            custom_days = [_BOARD_TO_DAY[c.strip()] for c in days_spec.split("+") if c.strip() in _BOARD_TO_DAY]
+            days = "custom" if custom_days else "daily"
+        slots.append({
+            "start_time": start,
+            "end_time": end,
+            "days": days,
+            "custom_days": custom_days,
+        })
+    return slots
+
+
+# ---------------------------------------------------------------------------
+# Clock sync
+# ---------------------------------------------------------------------------
+
+def posix_tz(iana_name: str | None = None) -> str | None:
+    """POSIX TZ rule string for an IANA zone (or the system zone when None).
+
+    Modern TZif files (v2+) end with a footer line holding exactly this string
+    (e.g. 'EST5EDT,M3.2.0,M11.1.0'), which is what the firmware's $Time/Zone
+    wants. Returns None when the zone file can't be read.
+    """
+    path = f"/usr/share/zoneinfo/{iana_name}" if iana_name else "/etc/localtime"
+    try:
+        with open(path, "rb") as f:
+            data = f.read()
+        if not data.startswith(b"TZif"):
+            return None
+        # Footer = text between the last two newlines of the file.
+        end = data.rfind(b"\n")
+        if end <= 0:
+            return None
+        begin = data.rfind(b"\n", 0, end)
+        footer = data[begin + 1:end].decode("ascii").strip()
+        return footer or None
+    except Exception as e:
+        logger.debug(f"Could not derive POSIX tz for {iana_name or 'system'}: {e}")
+        return None
+
+
+def sync_board_time(conn=None) -> dict:
+    """Push the host's clock (and effective quiet-hours timezone) to the board.
+
+    Quiet hours are computed host-side in state.scheduled_pause_timezone (or the
+    system zone); pushing the same zone keeps board-side schedules (autostart,
+    firmware playlists) aligned with what the user sees in the UI.
+    """
+    conn = conn or state.conn
+    if not conn:
+        raise RuntimeError("No board connection")
+    tz = posix_tz(state.scheduled_pause_timezone)
+    result = conn.set_time(epoch=int(time.time()), tz=tz)
+    logger.info(f"Synced board clock (tz={tz or 'unchanged'}): {result}")
+    return result
+
+
+# ---------------------------------------------------------------------------
+# Still Sands push / adopt
+# ---------------------------------------------------------------------------
+
+def push_still_sands(conn=None) -> None:
+    """Write the host's Still Sands settings to the board's $Sands/* NVS keys."""
+    conn = conn or state.conn
+    if not conn:
+        return
+    conn.set_setting("Sands/Enabled", "ON" if state.scheduled_pause_enabled else "OFF")
+    slots = slots_to_board(state.scheduled_pause_time_slots)
+    if slots:
+        conn.set_setting("Sands/Slots", slots)
+    conn.set_setting("Sands/FinishPattern", "ON" if state.scheduled_pause_finish_pattern else "OFF")
+    # One UI toggle drives both LED systems: host WLED and the board's own ring.
+    conn.set_setting("Sands/LedOff", "ON" if state.scheduled_pause_control_wled else "OFF")
+    logger.info("Pushed Still Sands settings to board")
+
+
+def adopt_still_sands(settings_map: dict) -> bool:
+    """Adopt the board's $Sands/* values into host state (mobile-app edits win).
+
+    Returns True when anything changed. The timezone is not adopted: the board
+    stores a POSIX rule derived from the host's IANA zone, which isn't
+    reversible — the host zone selection stays authoritative.
+    """
+    enabled = _is_on(settings_map.get("Sands/Enabled"))
+    finish = _is_on(settings_map.get("Sands/FinishPattern", "ON"))
+    led_off = _is_on(settings_map.get("Sands/LedOff"))
+    slots = board_to_slots(settings_map.get("Sands/Slots", ""))
+
+    changed = (
+        enabled != state.scheduled_pause_enabled
+        or finish != state.scheduled_pause_finish_pattern
+        or led_off != state.scheduled_pause_control_wled
+        or slots != state.scheduled_pause_time_slots
+    )
+    if changed:
+        state.scheduled_pause_enabled = enabled
+        state.scheduled_pause_finish_pattern = finish
+        state.scheduled_pause_control_wled = led_off
+        state.scheduled_pause_time_slots = slots
+        state.save()
+        logger.info("Adopted Still Sands settings from board")
+    return changed
+
+
+# ---------------------------------------------------------------------------
+# Auto-home cadence ($Playlist/AutoHome) — mirrors the host auto_home settings
+# so firmware-sequenced playlists (autostart) drift-correct the same way.
+# ---------------------------------------------------------------------------
+
+def push_auto_home(conn=None) -> None:
+    conn = conn or state.conn
+    if not conn:
+        return
+    every = state.auto_home_after_patterns if state.auto_home_enabled else 0
+    conn.set_setting("Playlist/AutoHome", max(0, int(every or 0)))
+
+
+# ---------------------------------------------------------------------------
+# Auto-play on boot ($Playlist/Autostart*) — board-only, proxied for the web UI
+# ---------------------------------------------------------------------------
+
+def get_board_settings(conn=None) -> dict:
+    """Shape the board's /sand_settings + clock into the web UI's structure."""
+    conn = conn or state.conn
+    if not conn:
+        raise RuntimeError("No board connection")
+    s = conn.get_settings()
+    status = conn.get_status()
+    return {
+        "reachable": True,
+        "firmware_version": status.get("fw"),
+        "state": status.get("state"),
+        "time": status.get("time") or conn.get_time(),
+        "autostart": {
+            "playlist": s.get("Playlist/Autostart", ""),
+            "run_mode": "single" if (s.get("Playlist/AutostartMode", "loop").lower() == "single") else "loop",
+            "shuffle": _is_on(s.get("Playlist/AutostartShuffle")),
+            "pause_seconds": int(float(s.get("Playlist/AutostartPause", 0) or 0)),
+            "pause_from_start": _is_on(s.get("Playlist/AutostartPauseFromStart")),
+            "clear_pattern": s.get("Playlist/AutostartClear", "none") or "none",
+        },
+        "homing_mode": (s.get("Sand/HomingMode") or "sensor").lower(),
+        "theta_offset": float(s.get("Sand/ThetaOffset", 0) or 0),
+        "auto_home_every": int(float(s.get("Playlist/AutoHome", 0) or 0)),
+    }
+
+
+def apply_autostart(update: dict, conn=None) -> None:
+    """Write autostart fields to the board. `update` uses the UI field names."""
+    conn = conn or state.conn
+    if not conn:
+        raise RuntimeError("No board connection")
+    if "playlist" in update:
+        # Empty value disables auto-play on boot.
+        conn.set_setting("Playlist/Autostart", update["playlist"] or "")
+    if "run_mode" in update:
+        mode = "single" if update["run_mode"] == "single" else "loop"
+        conn.set_setting("Playlist/AutostartMode", mode)
+    if "shuffle" in update:
+        conn.set_setting("Playlist/AutostartShuffle", "ON" if update["shuffle"] else "OFF")
+    if "pause_seconds" in update:
+        conn.set_setting("Playlist/AutostartPause", max(0, int(update["pause_seconds"] or 0)))
+    if "pause_from_start" in update:
+        conn.set_setting("Playlist/AutostartPauseFromStart", "ON" if update["pause_from_start"] else "OFF")
+    if "clear_pattern" in update:
+        clear = update["clear_pattern"] if update["clear_pattern"] in CLEAR_MODES else "none"
+        conn.set_setting("Playlist/AutostartClear", clear)
+
+
+# ---------------------------------------------------------------------------
+# Playlist mirroring — firmware playlists are /playlists/<name>.txt on the SD,
+# one SD pattern path per line. Autostart runs these, so host playlist CRUD is
+# mirrored (and the selected playlist's patterns are ensured on the board).
+# ---------------------------------------------------------------------------
+
+def _playlist_sd_content(files: list) -> str:
+    from modules.core.pattern_manager import _to_sd_path
+    lines = [_to_sd_path(f) for f in files or []]
+    return "\n".join(lines) + "\n"
+
+
+def mirror_playlist(name: str, files: list, conn=None, ensure_patterns: bool = False) -> None:
+    """Write a host playlist to the board as /playlists/<name>.txt (best-effort)."""
+    conn = conn or state.conn
+    if not conn:
+        return
+    try:
+        sd_path = f"/playlists/{name}.txt"
+        data = _playlist_sd_content(files).encode("utf-8")
+        conn.upload_file(sd_path, data, "/playlists")
+        logger.info(f"Mirrored playlist '{name}' to board ({len(files or [])} patterns)")
+    except Exception as e:
+        logger.warning(f"Could not mirror playlist '{name}' to board: {e}")
+        return
+    if ensure_patterns:
+        from modules.core.pattern_manager import _ensure_on_board, _to_sd_path
+        for f in files or []:
+            _ensure_on_board(f, _to_sd_path(f))
+
+
+def mirror_playlist_async(name: str, files: list) -> None:
+    """Fire-and-forget mirror from sync code paths (never blocks the caller)."""
+    threading.Thread(target=mirror_playlist, args=(name, files), daemon=True).start()
+
+
+def unmirror_playlist_async(name: str) -> None:
+    threading.Thread(target=unmirror_playlist, args=(name,), daemon=True).start()
+
+
+def unmirror_playlist(name: str, conn=None) -> None:
+    """Delete a playlist's mirror from the board SD (best-effort)."""
+    conn = conn or state.conn
+    if not conn:
+        return
+    try:
+        conn.delete_file("/playlists", f"{name}.txt")
+        logger.info(f"Removed playlist '{name}' from board")
+    except Exception as e:
+        logger.debug(f"Could not remove playlist '{name}' from board: {e}")
+
+
+def mirror_all_playlists(conn=None) -> None:
+    """Mirror every host playlist to the board (run on connect, best-effort)."""
+    from modules.core import playlist_manager
+    conn = conn or state.conn
+    if not conn:
+        return
+    try:
+        playlists = playlist_manager.load_playlists()
+    except Exception as e:
+        logger.warning(f"Could not load playlists for mirroring: {e}")
+        return
+    for name, entry in playlists.items():
+        files = entry.get("files", entry) if isinstance(entry, dict) else entry
+        mirror_playlist(name, files, conn=conn)
+
+
+# ---------------------------------------------------------------------------
+# Custom clear patterns — the firmware picks and runs its own clear files
+# (/patterns/clear_from_in.thr, clear_from_out.thr per its playlist: config).
+# A "custom" clear is implemented by uploading the chosen pattern's content
+# over those fixed paths (and restoring the stock file when cleared).
+# ---------------------------------------------------------------------------
+
+def push_custom_clears(conn=None) -> None:
+    """Upload the effective clear files to the board (best effort)."""
+    conn = conn or state.conn
+    if not conn:
+        return
+    from modules.core.pattern_manager import THETA_RHO_DIR
+    import os
+    for board_name, custom in (
+        ("clear_from_in.thr", state.custom_clear_from_in),
+        ("clear_from_out.thr", state.custom_clear_from_out),
+    ):
+        source = os.path.join(THETA_RHO_DIR, custom) if custom \
+            else os.path.join(THETA_RHO_DIR, board_name)
+        if not os.path.exists(source):
+            logger.warning(f"Clear source missing, not mirrored: {source}")
+            continue
+        try:
+            with open(source, "rb") as f:
+                conn.upload_file(f"/patterns/{board_name}", f.read(), "/patterns")
+            logger.info(f"Board clear file {board_name} <- {os.path.basename(source)}")
+        except Exception as e:
+            logger.warning(f"Could not push clear file {board_name}: {e}")
+
+
+def push_custom_clears_async() -> None:
+    threading.Thread(target=push_custom_clears, daemon=True).start()
+
+
+# ---------------------------------------------------------------------------
+# Connect-time reconciliation, called after the board connection is up.
+# ---------------------------------------------------------------------------
+
+def sync_on_connect(conn=None) -> None:
+    """Clock push + Still Sands adopt + AutoHome push + playlist mirror."""
+    conn = conn or state.conn
+    if not conn:
+        return
+    try:
+        sync_board_time(conn)
+    except Exception as e:
+        logger.warning(f"Board clock sync failed: {e}")
+    try:
+        adopt_still_sands(conn.get_settings())
+    except Exception as e:
+        logger.warning(f"Could not adopt Still Sands settings from board: {e}")
+    try:
+        push_auto_home(conn)
+    except Exception as e:
+        logger.warning(f"Could not push auto-home cadence to board: {e}")
+    mirror_all_playlists(conn)

+ 46 - 21
modules/core/cache_manager.py

@@ -343,8 +343,14 @@ def save_metadata_cache(cache_data):
         else:
             structured_cache = cache_data
         
-        with open(METADATA_CACHE_FILE, 'w') as f:
+        # Atomic replace: write to a temp file, then rename over the real one.
+        # A crash/kill mid-write must never leave a truncated JSON — a corrupt
+        # cache is silently invalidated on the next boot, forcing a full
+        # 1000+ pattern regeneration.
+        tmp_path = METADATA_CACHE_FILE + ".tmp"
+        with open(tmp_path, 'w') as f:
             json.dump(structured_cache, f, indent=2)
+        os.replace(tmp_path, METADATA_CACHE_FILE)
     except Exception as e:
         logger.error(f"Failed to save metadata cache: {str(e)}")
 
@@ -386,33 +392,48 @@ async def get_pattern_metadata_async(pattern_file):
     
     return None
 
-async def cache_pattern_metadata(pattern_file, first_coord, last_coord, total_coords):
-    """Cache metadata for a pattern file.
+async def cache_pattern_metadata_batch(entries):
+    """Cache metadata for many patterns with a single read-modify-write.
 
-    Uses asyncio.Lock to prevent race conditions when multiple concurrent tasks
-    (from asyncio.gather) try to read-modify-write the cache file simultaneously.
+    entries: iterable of (pattern_file, first_coord, last_coord, total_coords).
+    One whole-file rewrite per batch instead of per pattern — during a full
+    generation this is the difference between ~1080 and ~360 rewrites of a
+    growing JSON. Uses asyncio.Lock to prevent race conditions when concurrent
+    tasks read-modify-write the cache file simultaneously.
     """
+    entries = list(entries)
+    if not entries:
+        return
     async with _get_metadata_cache_lock():
         try:
             cache_data = await asyncio.to_thread(load_metadata_cache)
             data_section = cache_data.get('data', {})
-            pattern_path = os.path.join(THETA_RHO_DIR, pattern_file)
-            file_mtime = await asyncio.to_thread(os.path.getmtime, pattern_path)
-
-            data_section[pattern_file] = {
-                'mtime': file_mtime,
-                'metadata': {
-                    'first_coordinate': first_coord,
-                    'last_coordinate': last_coord,
-                    'total_coordinates': total_coords
+            for pattern_file, first_coord, last_coord, total_coords in entries:
+                pattern_path = os.path.join(THETA_RHO_DIR, pattern_file)
+                try:
+                    file_mtime = await asyncio.to_thread(os.path.getmtime, pattern_path)
+                except OSError as e:
+                    logger.warning(f"Failed to cache metadata for {pattern_file}: {str(e)}")
+                    continue
+                data_section[pattern_file] = {
+                    'mtime': file_mtime,
+                    'metadata': {
+                        'first_coordinate': first_coord,
+                        'last_coordinate': last_coord,
+                        'total_coordinates': total_coords
+                    }
                 }
-            }
+                logger.debug(f"Cached metadata for {pattern_file}")
 
             cache_data['data'] = data_section
             await asyncio.to_thread(save_metadata_cache, cache_data)
-            logger.debug(f"Cached metadata for {pattern_file}")
         except Exception as e:
-            logger.warning(f"Failed to cache metadata for {pattern_file}: {str(e)}")
+            logger.warning(f"Failed to cache metadata batch: {str(e)}")
+
+
+async def cache_pattern_metadata(pattern_file, first_coord, last_coord, total_coords):
+    """Cache metadata for a single pattern file."""
+    await cache_pattern_metadata_batch([(pattern_file, first_coord, last_coord, total_coords)])
 
 def needs_cache(pattern_file):
     """Check if a pattern file needs its cache generated."""
@@ -630,12 +651,13 @@ async def generate_metadata_cache():
         successful = 0
         for i in range(0, total_files, batch_size):
             batch = files_to_process[i:i + batch_size]
-            
+            batch_entries = []
+
             # Process files sequentially within batch (no parallel tasks)
             for file_name in batch:
                 pattern_path = os.path.join(THETA_RHO_DIR, file_name)
                 cache_progress["current_file"] = file_name
-                
+
                 try:
                     # Parse file to get metadata
                     coordinates = await asyncio.to_thread(parse_theta_rho_file, pattern_path)
@@ -645,8 +667,7 @@ async def generate_metadata_cache():
                         last_coord = {"x": coordinates[-1][0], "y": coordinates[-1][1]}
                         total_coords = len(coordinates)
 
-                        # Cache the metadata
-                        await cache_pattern_metadata(file_name, first_coord, last_coord, total_coords)
+                        batch_entries.append((file_name, first_coord, last_coord, total_coords))
                         successful += 1
                         logger.debug(f"Generated metadata for {file_name}")
 
@@ -655,6 +676,10 @@ async def generate_metadata_cache():
 
                 except Exception as e:
                     logger.error(f"Failed to generate metadata for {file_name}: {str(e)}")
+
+            # One cache-file rewrite per batch, so progress survives restarts
+            # without hammering the disk once per pattern.
+            await cache_pattern_metadata_batch(batch_entries)
             
             # Update progress
             cache_progress["processed_files"] = min(i + batch_size, total_files)

+ 652 - 0
modules/core/execution.py

@@ -0,0 +1,652 @@
+"""
+Firmware-delegated execution: the board runs everything, the host proxies
+commands and observes status.
+
+The FluidNC firmware owns kinematics, `.thr` playback, playlist sequencing,
+pre-execution clears, quiet hours and auto-home. This module is the host's
+entire runtime layer on top of that:
+
+  - Commands: each user action is one or a few HTTP calls to the board
+    ($Sand/Run, $Playlist/Run, /sand_stop, /sand_pause, /sand_resume,
+    $Playlist/Skip, /sand_feed, /sand_goto).
+  - Truth: /sand_status is the single source of runtime state. One observer
+    task polls it, translates it into the frontend's /ws/status contract,
+    detects edges (file transitions -> play history, Hold -> pause accounting,
+    clearing -> clear-speed shim, run end -> state reset) and broadcasts.
+  - Context: the host remembers what run *it* started (RunContext) to fill the
+    contract fields the board doesn't report (playlist files, run mode).
+
+No sequencing happens on the host. If the board reboots or the backend
+restarts mid-run, the observer resynchronizes from /sand_status alone.
+"""
+
+import asyncio
+import logging
+import os
+import time
+from dataclasses import dataclass, field
+from typing import Callable, Literal, Optional
+
+from modules.connection import connection_manager
+from modules.core.state import state
+
+logger = logging.getLogger(__name__)
+
+# Firmware clear modes; legacy host values are mapped onto them.
+CLEAR_MODES = ("none", "adaptive", "in", "out", "sideway", "random")
+_LEGACY_CLEAR = {
+    "clear_from_in": "in",
+    "clear_from_out": "out",
+    "clear_sideway": "sideway",
+    "clear_in": "in",
+    "clear_out": "out",
+}
+
+
+class ExecutionError(Exception):
+    """Raised when the board rejects or times out on an execution command."""
+
+
+@dataclass
+class RunContext:
+    """Host-side knowledge about the run we started (not board truth)."""
+    kind: Literal["pattern", "playlist"]
+    playlist_name: Optional[str] = None
+    files: Optional[list] = None  # host paths, unshuffled mirror order
+    run_mode: str = "single"      # frontend value: 'single' | 'indefinite'
+    shuffle: bool = False
+    clear_pattern: str = "none"
+    started_at: float = field(default_factory=time.time)
+
+
+current_run: Optional[RunContext] = None
+
+
+def map_clear_mode(value) -> str:
+    """Map a frontend/legacy clear value onto the firmware's clear modes."""
+    if not value:
+        return "none"
+    value = str(value).lower()
+    if value in CLEAR_MODES:
+        return value
+    if value in _LEGACY_CLEAR:
+        return _LEGACY_CLEAR[value]
+    logger.warning(f"Unknown clear mode '{value}', using none")
+    return "none"
+
+
+def _state(st: Optional[dict]) -> str:
+    """Machine state without the GRBL substate suffix ('Hold:0' -> 'Hold')."""
+    return ((st or {}).get("state") or "").split(":", 1)[0]
+
+
+def _from_sd_path(sd_path: str) -> Optional[str]:
+    """Map a board SD path ('/patterns/x.thr', '/sd/patterns/x.thr') to the
+    host-relative path ('./patterns/x.thr') the frontend/history expect."""
+    if not sd_path:
+        return None
+    p = sd_path.replace("\\", "/")
+    if p.startswith("/sd/"):
+        p = p[3:]
+    if not p.startswith("/"):
+        p = "/" + p
+    return "." + p
+
+
+# ---------------------------------------------------------------------------
+# Commands
+# ---------------------------------------------------------------------------
+
+def _require_conn():
+    if not state.conn or not state.conn.is_connected():
+        raise ExecutionError("Connection not established")
+    return state.conn
+
+
+async def _wait_for_idle(timeout: float = 15.0) -> bool:
+    """Poll the board until it reports Idle (used before idle-gated NVS writes)."""
+    deadline = time.time() + timeout
+    while time.time() < deadline:
+        try:
+            st = await asyncio.to_thread(state.conn.get_status)
+            if _state(st) == "Idle" and not st.get("running"):
+                return True
+        except Exception as e:
+            logger.debug(f"Idle wait poll failed: {e}")
+        await asyncio.sleep(0.5)
+    return False
+
+
+async def run_pattern(file_path: str, clear_pattern: str = "none") -> None:
+    """Run one pattern via $Sand/Run (firmware sequences clear -> pattern and
+    aborts any current job first)."""
+    global current_run
+    conn = _require_conn()
+    from modules.core.pattern_manager import _ensure_on_board, _to_sd_path
+
+    sd_path = _to_sd_path(file_path)
+    await asyncio.to_thread(_ensure_on_board, file_path, sd_path)
+    try:
+        await asyncio.to_thread(conn.set_feed, int(state.speed))
+    except Exception as e:
+        logger.warning(f"Could not set feed before run: {e}")
+    mode = map_clear_mode(clear_pattern)
+    await asyncio.to_thread(conn.run_pattern, sd_path, mode)
+
+    current_run = RunContext(kind="pattern", clear_pattern=mode)
+    state.current_playlist = None
+    state.current_playlist_name = None
+    # Optimistic; the observer confirms/corrects from /sand_status.
+    state.current_playing_file = file_path
+    observer.on_run_started()
+
+
+async def start_playlist(playlist_name: str, run_mode: str = "single",
+                         pause_time: float = 0, clear_pattern: str = "none",
+                         shuffle: bool = False) -> None:
+    """Run a playlist on the board via $Playlist/Run.
+
+    The run options are the firmware's NVS $Playlist/* globals; NVS writes are
+    idle-gated, so a run-while-running stops the board first.
+    """
+    global current_run
+    conn = _require_conn()
+    from modules.core import playlist_manager, board_settings
+    from modules.core.pattern_manager import THETA_RHO_DIR, _ensure_on_board, _to_sd_path
+
+    playlist = playlist_manager.get_playlist(playlist_name)
+    if not playlist:
+        raise ExecutionError(f"Playlist '{playlist_name}' not found")
+    files = playlist["files"]
+    if not files:
+        raise ExecutionError(f"Playlist '{playlist_name}' is empty")
+    host_paths = [os.path.join(THETA_RHO_DIR, f) for f in files]
+
+    # Idle-gate: NVS settings writes are rejected mid-motion.
+    st = None
+    try:
+        st = await asyncio.to_thread(conn.get_status)
+    except Exception:
+        pass
+    if st and (st.get("running") or _state(st) not in ("Idle", "Alarm")):
+        await asyncio.to_thread(conn.stop)
+        if not await _wait_for_idle(15.0):
+            raise ExecutionError("Table is busy and did not stop in time")
+
+    mode = map_clear_mode(clear_pattern)
+    for key, value in (
+        ("Playlist/Mode", "loop" if run_mode == "indefinite" else "single"),
+        ("Playlist/Shuffle", "ON" if shuffle else "OFF"),
+        ("Playlist/PauseTime", max(0, int(pause_time or 0))),
+        ("Playlist/ClearPattern", mode),
+    ):
+        await asyncio.to_thread(conn.set_setting, key, value)
+
+    # The board needs the playlist file before Run; patterns can trail behind
+    # (each one plays for minutes) except the first, which is ensured now.
+    await asyncio.to_thread(board_settings.mirror_playlist, playlist_name, files, conn)
+    await asyncio.to_thread(_ensure_on_board, host_paths[0], _to_sd_path(host_paths[0]))
+
+    def _mirror_rest():
+        for p in host_paths[1:]:
+            _ensure_on_board(p, _to_sd_path(p))
+    import threading
+    threading.Thread(target=_mirror_rest, daemon=True).start()
+
+    try:
+        await asyncio.to_thread(conn.set_feed, int(state.speed))
+    except Exception as e:
+        logger.warning(f"Could not set feed before playlist: {e}")
+    await asyncio.to_thread(conn.run_command, f"$Playlist/Run={playlist_name}")
+
+    current_run = RunContext(
+        kind="playlist", playlist_name=playlist_name, files=host_paths,
+        run_mode=run_mode, shuffle=shuffle, clear_pattern=mode,
+    )
+    state.current_playlist = host_paths
+    state.current_playlist_name = playlist_name
+    state.playlist_mode = run_mode
+    observer.on_run_started()
+    logger.info(f"Started playlist '{playlist_name}' on the board "
+                f"(mode={run_mode}, shuffle={shuffle}, pause={pause_time}s, clear={mode})")
+
+
+async def stop(force: bool = False) -> bool:
+    """Clean stop. Returns True once the board is Idle (always True for force)."""
+    try:
+        conn = _require_conn()
+        await asyncio.to_thread(conn.stop)
+    except Exception as e:
+        if not force:
+            raise ExecutionError(f"Stop failed: {e}")
+        logger.warning(f"Force stop: board stop failed ({e}), resetting host state anyway")
+    if force:
+        _reset_run_state()
+        observer.reset()
+        return True
+    return await _wait_for_idle(10.0)
+
+
+async def pause() -> None:
+    conn = _require_conn()
+    await asyncio.to_thread(conn.pause)
+
+
+async def resume() -> None:
+    conn = _require_conn()
+    await asyncio.to_thread(conn.resume)
+
+
+async def skip() -> bool:
+    """Skip the current pattern: $Playlist/Skip for playlists (also overrides
+    quiet hours for one pattern); stopping is the 'skip' of a single pattern."""
+    conn = _require_conn()
+    st = observer.last_raw or {}
+    pl = st.get("playlist") or {}
+    if pl.get("active"):
+        await asyncio.to_thread(conn.skip)
+        return True
+    if st.get("running"):
+        await asyncio.to_thread(conn.stop)
+        return True
+    return False
+
+
+async def set_speed(speed: float) -> None:
+    conn = _require_conn()
+    state.speed = speed
+    await asyncio.to_thread(conn.set_feed, int(speed))
+
+
+def _reset_run_state() -> None:
+    global current_run
+    current_run = None
+    state.current_playing_file = None
+    state.current_playlist = None
+    state.current_playlist_name = None
+    state.pause_requested = False
+    state.pause_time_remaining = 0
+    state.execution_progress = None
+
+
+# ---------------------------------------------------------------------------
+# Status translation: /sand_status -> the frontend's /ws/status contract
+# ---------------------------------------------------------------------------
+
+def translate_status(st: Optional[dict], obs: Optional["BoardObserver"] = None,
+                     now: Optional[float] = None) -> dict:
+    """Translate a raw board status into the /ws/status data object.
+
+    Pure given (st, observer timing, state); unit-testable with fixtures.
+    """
+    obs = obs or observer
+    now = now if now is not None else time.time()
+    connected = bool(state.conn and state.conn.is_connected())
+
+    if st is None:
+        return {
+            "connection_status": connected,
+            "current_file": None,
+            "is_running": False,
+            "is_paused": False,
+            "is_homing": state.is_homing,
+            "sensor_homing_failed": state.sensor_homing_failed,
+            "is_clearing": False,
+            "speed": state.speed,
+            "pause_time_remaining": 0,
+            "original_pause_time": None,
+            "progress": None,
+            "playlist": None,
+            "current_theta": state.current_theta,
+            "current_rho": state.current_rho,
+            "firmware_version": state.firmware_version,
+            "table_type": None,
+        }
+
+    pl = st.get("playlist") or {}
+    running = bool(st.get("running"))
+    # GRBL states can carry a substate suffix (e.g. "Hold:0", "Door:1").
+    machine_state = _state(st)
+    clearing = bool(pl.get("clearing"))
+    pause_remaining = pl.get("pause_remaining", -1)
+    pause_total = pl.get("pause_total", -1)
+
+    current_file = _from_sd_path(st.get("file")) if running else None
+
+    # --- progress ---
+    progress_obj = None
+    if running:
+        fraction = st.get("progress", -1)
+        fraction = fraction if isinstance(fraction, (int, float)) and fraction >= 0 else 0
+        elapsed = max(0.0, now - obs.file_started_at - obs.hold_accumulated
+                      - (now - obs.hold_started_at if obs.hold_started_at else 0))
+        if fraction > 0.02:
+            remaining = max(0.0, elapsed / fraction - elapsed)
+        elif obs.cached_history and obs.cached_history.get("actual_time_seconds"):
+            remaining = max(0.0, obs.cached_history["actual_time_seconds"] - elapsed)
+        else:
+            remaining = None
+        progress_obj = {
+            "current": int(fraction * 1000),
+            "total": 1000,
+            "percentage": round(fraction * 100, 1),
+            "elapsed_time": elapsed,
+            "remaining_time": remaining,
+        }
+        if obs.cached_history:
+            progress_obj["last_completed_time"] = obs.cached_history
+
+    # --- playlist ---
+    playlist_obj = None
+    playlist_active = bool(pl.get("active"))
+    ctx = current_run
+    if (playlist_active and pl.get("total", 0) > 0) or (ctx and ctx.kind == "playlist"):
+        files = [f for f in (state.current_playlist or [])]
+        index = int(pl.get("index", 0) or 0)
+        total = int(pl.get("total", 0) or 0) or len(files)
+        shuffled = bool(ctx.shuffle) if ctx else False
+        next_file = None
+        if files and not shuffled:
+            # While clearing, the "next" thing is the pattern the clear precedes.
+            next_idx = index if clearing else index + 1
+            if 0 <= next_idx < len(files):
+                next_file = files[next_idx]
+        playlist_obj = {
+            "name": pl.get("name") or (ctx.playlist_name if ctx else None),
+            "current_index": index,
+            "total_files": total,
+            "mode": (ctx.run_mode if ctx else "indefinite"),
+            "files": files,
+            "next_file": next_file,
+            "shuffled": shuffled,
+        }
+
+    return {
+        "connection_status": connected,
+        "current_file": current_file,
+        "is_running": running,
+        "is_paused": machine_state == "Hold",
+        "is_homing": machine_state == "Home" or state.is_homing,
+        "sensor_homing_failed": state.sensor_homing_failed,
+        "is_clearing": clearing,
+        "speed": state.speed,
+        "pause_time_remaining": pause_remaining if pause_remaining >= 0 else 0,
+        "original_pause_time": pause_total if pause_total >= 0 else None,
+        "progress": progress_obj,
+        "playlist": playlist_obj,
+        "current_theta": st.get("theta", state.current_theta),
+        "current_rho": st.get("rho", state.current_rho),
+        "firmware_version": st.get("fw") or state.firmware_version,
+        "table_type": None,
+    }
+
+
+# ---------------------------------------------------------------------------
+# Observer: single poll loop — edges, history, shims, broadcast
+# ---------------------------------------------------------------------------
+
+class BoardObserver:
+    """Polls /sand_status and turns transitions into host behavior."""
+
+    POLL_ACTIVE = 1.0
+    POLL_IDLE = 2.0
+    OFFLINE_GRACE = 3  # consecutive failures before a run is declared over
+
+    def __init__(self):
+        self.prev: Optional[dict] = None
+        self.last_raw: Optional[dict] = None
+        self.last_translated: dict = {}
+        self.file_started_at: float = 0.0
+        self.hold_started_at: Optional[float] = None
+        self.hold_accumulated: float = 0.0
+        self.last_progress: float = -1.0
+        self.cached_history: Optional[dict] = None
+        self._clear_speed_saved: Optional[float] = None
+        self._was_quiet_wled_off = False
+        self._fail_count = 0
+        self._tick = 0
+        self._task: Optional[asyncio.Task] = None
+        # Set by main.py to fan out to /ws/status clients (avoids circular import).
+        self.on_status: Optional[Callable] = None
+
+    def reset(self) -> None:
+        self.prev = None
+        self.file_started_at = 0.0
+        self.hold_started_at = None
+        self.hold_accumulated = 0.0
+        self.last_progress = -1.0
+        self.cached_history = None
+        self._clear_speed_saved = None
+
+    def on_run_started(self) -> None:
+        """Called by the command layer so the first poll attributes time correctly."""
+        self.file_started_at = time.time()
+        self.hold_accumulated = 0.0
+        self.hold_started_at = None
+        self.last_progress = -1.0
+
+    # -- lifecycle ---------------------------------------------------------
+
+    def start(self) -> None:
+        self._task = asyncio.create_task(self._run())
+
+    async def astop(self) -> None:
+        if self._task:
+            self._task.cancel()
+            try:
+                await self._task
+            except asyncio.CancelledError:
+                pass
+
+    async def _run(self) -> None:
+        while True:
+            interval = self.POLL_IDLE
+            try:
+                interval = await self._tick_once()
+            except asyncio.CancelledError:
+                raise
+            except Exception as e:
+                logger.warning(f"Status observer tick failed: {e}")
+            await asyncio.sleep(interval)
+
+    async def _tick_once(self) -> float:
+        st = None
+        if state.conn and state.conn.is_connected():
+            try:
+                st = await asyncio.to_thread(connection_manager.poll_status_once)
+            except Exception as e:
+                logger.debug(f"Status poll failed: {e}")
+        await self.process(st)
+        active = bool(st and (st.get("running") or (st.get("playlist") or {}).get("active")
+                              or _state(st) in ("Hold", "Home", "Jog")))
+        return self.POLL_ACTIVE if active else self.POLL_IDLE
+
+    # -- core (sync-friendly for tests; only I/O bits are awaited) ----------
+
+    async def process(self, st: Optional[dict], now: Optional[float] = None) -> dict:
+        """One observation step: edge detection + translation + broadcast."""
+        now = now if now is not None else time.time()
+
+        if st is None:
+            self._fail_count += 1
+            if self._fail_count == self.OFFLINE_GRACE and self.prev is not None:
+                # Board gone: close out the run without claiming completion.
+                logger.warning("Board unreachable — closing out the observed run")
+                self._close_file(self.prev, now, aborted=True)
+                _reset_run_state()
+                self.reset()
+        else:
+            self._fail_count = 0
+            if self.prev is not None and st.get("uptime", 0) < self.prev.get("uptime", 0):
+                logger.warning("Board rebooted mid-observation — resetting run context")
+                _reset_run_state()
+                self.reset()
+            self._detect_edges(st, now)
+            self.prev = st
+            self.last_raw = st
+
+        await self._quiet_hours_wled(now)
+
+        self._tick += 1
+        if self._tick % 30 == 0 and state.conn and state.conn.is_connected():
+            try:
+                from modules.core import board_settings
+                settings_map = await asyncio.to_thread(state.conn.get_settings)
+                board_settings.adopt_still_sands(settings_map)
+            except Exception as e:
+                logger.debug(f"Board settings adopt failed: {e}")
+
+        self.last_translated = translate_status(st, self, now)
+        # Mirror the progress 4-tuple for the MQTT handler, which unpacks it.
+        prog = self.last_translated.get("progress")
+        state.execution_progress = (
+            (prog["current"], prog["total"], prog["remaining_time"], prog["elapsed_time"])
+            if prog else None
+        )
+        if self.on_status:
+            try:
+                await self.on_status(self.last_translated)
+            except Exception as e:
+                logger.debug(f"Status broadcast failed: {e}")
+        return self.last_translated
+
+    def _detect_edges(self, st: dict, now: float) -> None:
+        prev = self.prev or {}
+        prev_pl = prev.get("playlist") or {}
+        pl = st.get("playlist") or {}
+        prev_file = prev.get("file") or ""
+        cur_file = st.get("file") or ""
+        prev_running = bool(prev.get("running"))
+        running = bool(st.get("running"))
+
+        # File end: the file changed while running, or playback stopped.
+        if prev_running and prev_file and (cur_file != prev_file or not running):
+            self._close_file(prev, now, aborted=False)
+
+        # File start.
+        if running and cur_file and (not prev_running or cur_file != prev_file):
+            self.file_started_at = now
+            self.hold_accumulated = 0.0
+            self.hold_started_at = None
+            self.last_progress = -1.0
+            host_path = _from_sd_path(cur_file)
+            state.current_playing_file = host_path
+            self._cache_history(host_path)
+            self._on_playing_leds()
+
+        if running and isinstance(st.get("progress"), (int, float)) and st["progress"] >= 0:
+            self.last_progress = st["progress"]
+
+        # Hold (pause) edges — for pause accounting and the MQTT mirror.
+        prev_hold = _state(prev) == "Hold"
+        hold = _state(st) == "Hold"
+        if hold and not prev_hold:
+            self.hold_started_at = now
+            state.pause_requested = True
+        elif prev_hold and not hold:
+            if self.hold_started_at:
+                self.hold_accumulated += now - self.hold_started_at
+            self.hold_started_at = None
+            state.pause_requested = False
+
+        # Clearing edges — clear-speed shim.
+        prev_clearing = bool(prev_pl.get("clearing"))
+        clearing = bool(pl.get("clearing"))
+        if clearing and not prev_clearing and state.clear_pattern_speed:
+            self._clear_speed_saved = state.speed
+            self._set_feed_safe(state.clear_pattern_speed)
+        elif prev_clearing and not clearing and self._clear_speed_saved:
+            self._set_feed_safe(self._clear_speed_saved)
+            self._clear_speed_saved = None
+
+        # Run end: nothing running, no active playlist, machine idle.
+        was_active = prev_running or bool(prev_pl.get("active"))
+        is_active = running or bool(pl.get("active"))
+        if was_active and not is_active and _state(st) in ("Idle", "Alarm"):
+            logger.info("Observed run end on the board")
+            _reset_run_state()
+            state.save()
+            self._on_idle_leds()
+
+    def _close_file(self, prev_st: dict, now: float, aborted: bool) -> None:
+        """Log history for the file that just finished/stopped."""
+        prev_pl = prev_st.get("playlist") or {}
+        prev_file = prev_st.get("file") or ""
+        if not prev_file or prev_pl.get("clearing"):
+            return  # clears are not history-worthy (matches old semantics)
+        from modules.core.pattern_manager import log_execution_time
+        hold_extra = (now - self.hold_started_at) if self.hold_started_at else 0.0
+        actual = max(0.0, now - self.file_started_at - self.hold_accumulated - hold_extra)
+        completed = (not aborted) and self.last_progress >= 0.98
+        try:
+            log_execution_time(
+                pattern_name=os.path.basename(prev_file),
+                table_type="fluidnc",
+                speed=int(state.speed or 0),
+                actual_time=actual,
+                total_coordinates=0,
+                was_completed=completed,
+            )
+        except Exception as e:
+            logger.warning(f"Could not log execution history: {e}")
+
+    def _cache_history(self, host_path: Optional[str]) -> None:
+        self.cached_history = None
+        if not host_path:
+            return
+        try:
+            from modules.core.pattern_manager import get_last_completed_execution_time
+            self.cached_history = get_last_completed_execution_time(
+                os.path.basename(host_path), state.speed)
+        except Exception as e:
+            logger.debug(f"History lookup failed: {e}")
+
+    def _set_feed_safe(self, mm: float) -> None:
+        try:
+            if state.conn:
+                state.conn.set_feed(int(mm))
+        except Exception as e:
+            logger.warning(f"Clear-speed feed change failed: {e}")
+
+    def _on_playing_leds(self) -> None:
+        if state.led_controller and state.led_automation_enabled:
+            try:
+                asyncio.get_running_loop().create_task(
+                    state.led_controller.effect_playing_async(None))
+            except RuntimeError:
+                pass
+
+    def _on_idle_leds(self) -> None:
+        if state.led_controller and state.led_automation_enabled:
+            try:
+                asyncio.get_running_loop().create_task(
+                    state.led_controller.effect_idle_async(None))
+            except RuntimeError:
+                pass
+
+    async def _quiet_hours_wled(self, now: float) -> None:
+        """The one surviving host Still Sands behavior: switch WLED off during
+        quiet hours (the board handles its own ring via $Sands/LedOff)."""
+        if state.led_provider != "wled" or not state.led_controller:
+            return
+        from modules.core.pattern_manager import is_in_scheduled_pause_period
+        in_quiet = bool(state.scheduled_pause_control_wled and is_in_scheduled_pause_period())
+        if in_quiet and not self._was_quiet_wled_off:
+            self._was_quiet_wled_off = True
+            await state.led_controller.set_power_async(0)
+            logger.info("Still Sands: WLED off")
+        elif not in_quiet and self._was_quiet_wled_off:
+            self._was_quiet_wled_off = False
+            await state.led_controller.set_power_async(1)
+            await state.led_controller.effect_idle_async(None)
+            logger.info("Still Sands: WLED restored")
+
+
+observer = BoardObserver()
+
+
+def get_cached_status() -> dict:
+    """Last translated status (what /ws/status clients get on connect)."""
+    if observer.last_translated:
+        return observer.last_translated
+    return translate_status(observer.last_raw, observer)

Diferenças do arquivo suprimidas por serem muito extensas
+ 15 - 1456
modules/core/pattern_manager.py


+ 10 - 66
modules/core/playlist_manager.py

@@ -1,17 +1,11 @@
 import json
 import os
 import logging
-import asyncio
-from typing import Optional
-from modules.core import pattern_manager
 from modules.core.state import state
 
 # Configure logging
 logger = logging.getLogger(__name__)
 
-# Track the current playlist task so we can cancel it properly
-_current_playlist_task: Optional[asyncio.Task] = None
-
 # Global state
 PLAYLISTS_FILE = os.path.join(os.getcwd(), "playlists.json")
 
@@ -68,6 +62,9 @@ def create_playlist(playlist_name, files):
     playlists_dict[playlist_name] = files
     save_playlists(playlists_dict)
     logger.info(f"Created/updated playlist '{playlist_name}' with {len(files)} files")
+    # Keep the board's /playlists/<name>.txt mirror in sync (autostart runs it).
+    from modules.core import board_settings
+    board_settings.mirror_playlist_async(playlist_name, files)
     return True
 
 def modify_playlist(playlist_name, files):
@@ -84,6 +81,8 @@ def delete_playlist(playlist_name):
     del playlists_dict[playlist_name]
     save_playlists(playlists_dict)
     logger.info(f"Deleted playlist: {playlist_name}")
+    from modules.core import board_settings
+    board_settings.unmirror_playlist_async(playlist_name)
     return True
 
 def add_to_playlist(playlist_name, pattern):
@@ -95,6 +94,8 @@ def add_to_playlist(playlist_name, pattern):
     playlists_dict[playlist_name].append(pattern)
     save_playlists(playlists_dict)
     logger.info(f"Added pattern '{pattern}' to playlist '{playlist_name}'")
+    from modules.core import board_settings
+    board_settings.mirror_playlist_async(playlist_name, playlists_dict[playlist_name])
     return True
 
 def rename_playlist(old_name, new_name):
@@ -122,65 +123,8 @@ def rename_playlist(old_name, new_name):
     del playlists_dict[old_name]
     save_playlists(playlists_dict)
     logger.info(f"Renamed playlist '{old_name}' to '{new_name}'")
+    from modules.core import board_settings
+    board_settings.unmirror_playlist_async(old_name)
+    board_settings.mirror_playlist_async(new_name, playlists_dict[new_name])
     return True, f"Playlist renamed to '{new_name}'"
 
-async def cancel_current_playlist():
-    """Cancel the current playlist task if one is running."""
-    global _current_playlist_task
-    if _current_playlist_task and not _current_playlist_task.done():
-        logger.info("Cancelling existing playlist task...")
-        _current_playlist_task.cancel()
-        try:
-            await _current_playlist_task
-        except asyncio.CancelledError:
-            logger.info("Playlist task cancelled successfully")
-        except Exception as e:
-            logger.warning(f"Error while cancelling playlist task: {e}")
-        _current_playlist_task = None
-
-async def run_playlist(playlist_name, pause_time=0, clear_pattern=None, run_mode="single", shuffle=False):
-    """Run a playlist with the given options."""
-    global _current_playlist_task
-
-    # Cancel any existing playlist task first
-    await cancel_current_playlist()
-
-    # Also stop any running pattern
-    if pattern_manager.get_pattern_lock().locked():
-        logger.info("Another pattern is running, stopping it first...")
-        await pattern_manager.stop_actions()
-
-    playlists = load_playlists()
-    if playlist_name not in playlists:
-        logger.error(f"Cannot run non-existent playlist: {playlist_name}")
-        return False, "Playlist not found"
-
-    file_paths = playlists[playlist_name]
-    file_paths = [os.path.join(pattern_manager.THETA_RHO_DIR, file) for file in file_paths]
-
-    if not file_paths:
-        logger.warning(f"Cannot run empty playlist: {playlist_name}")
-        return False, "Playlist is empty"
-
-    try:
-        logger.info(f"Starting playlist '{playlist_name}' with mode={run_mode}, shuffle={shuffle}")
-        # Set ALL playlist state variables BEFORE creating the async task.
-        # This ensures state is correct even if the task doesn't start immediately
-        # (important for TestClient which may cancel background tasks).
-        state.current_playlist = file_paths
-        state.current_playlist_name = playlist_name
-        state.playlist_mode = run_mode
-        state.current_playlist_index = 0
-        _current_playlist_task = asyncio.create_task(
-            pattern_manager.run_theta_rho_files(
-                file_paths,
-                pause_time=pause_time,
-                clear_pattern=clear_pattern,
-                run_mode=run_mode,
-                shuffle=shuffle,
-            )
-        )
-        return True, f"Playlist '{playlist_name}' is now running."
-    except Exception as e:
-        logger.error(f"Failed to run playlist '{playlist_name}': {str(e)}")
-        return False, str(e)

+ 4 - 279
modules/core/state.py

@@ -26,30 +26,13 @@ class AppState:
         self._current_playlist = None
         self._current_playlist_name = None  # New variable for playlist name
         
-        # Execution control flags (with event support for async waiting)
-        self._stop_requested = False
-        self._skip_requested = False
-        self._stop_event: Optional[asyncio.Event] = None
-        self._skip_event: Optional[asyncio.Event] = None
-        self._event_loop: Optional[asyncio.AbstractEventLoop] = None
-
         # Regular state variables
-        self.pause_condition = threading.Condition()
-        self.execution_progress = None
-        self.is_clearing = False
+        self.execution_progress = None  # (current, total, remaining, elapsed) mirror for MQTT
         self.current_theta = 0
         self.current_rho = 0
-        self.current_playlist_index = 0
         self.playlist_mode = "loop"
         self.pause_time_remaining = 0
-        self.active_clear_pattern = None  # Runtime: clear pattern mode for current playlist (not persisted)
-        
-        # Machine position variables
-        self.machine_x = 0.0
-        self.machine_y = 0.0
-        self.x_steps_per_mm = 0.0
-        self.y_steps_per_mm = 0.0
-        self.gear_ratio = 10
+        self.original_pause_time = None
 
         # Homing mode: 0 = crash homing, 1 = sensor homing ($H)
         self.homing = 0
@@ -57,10 +40,6 @@ class AppState:
         # When False/None, homing mode can be auto-detected from firmware ($22 setting)
         self.homing_user_override = False
 
-        # Homing state tracking (for sensor mode)
-        self.homed_x = False  # Set to True when [MSG:Homed:X] is received
-        self.homed_y = False  # Set to True when [MSG:Homed:Y] is received
-
         # Homing in progress flag - blocks other movement operations
         self.is_homing = False
 
@@ -85,7 +64,6 @@ class AppState:
         # When enabled, performs homing after X patterns during playlist execution
         self.auto_home_enabled = False
         self.auto_home_after_patterns = 5  # Number of patterns after which to auto-home
-        self.patterns_since_last_home = 0  # Counter for patterns played since last home
 
         # Hard reset on theta reset (sends $Bye to FluidNC to reset machine position)
         # When False (default), only normalizes theta to [0, 2π) without machine reset
@@ -97,37 +75,19 @@ class AppState:
         self.mqtt_handler = None  # Will be set by the MQTT handler
         self.conn = None
         self.port = None
-        self.preferred_port = None  # User's preferred port for auto-connect
         # Address of the FluidNC board (e.g. "http://192.168.68.160" or a bare IP).
         # None => fall back to the DUNE_BOARD_URL env var or the built-in default.
         self.board_url = None
         self.wled_ip = None
-        self.led_provider = "none"  # "wled", "dw_leds", or "none"
+        self.led_provider = "none"  # "wled", "board", or "none"
         self.led_controller = None
         self.screen_controller = None
 
-        # DW LED settings
-        self.dw_led_num_leds = 60  # Number of LEDs in strip
-        self.dw_led_gpio_pin = 18  # GPIO pin (12, 13, 18, or 19)
-        self.dw_led_pixel_order = "RGB"  # Pixel color order for WS281x (RGB for WS2815, GRB for WS2812)
-        self.dw_led_brightness = 35  # Brightness 0-100
-        self.dw_led_speed = 50  # Effect speed 0-255
-        self.dw_led_intensity = 128  # Effect intensity 0-255
-
-        # Idle effect settings (all parameters)
-        self.dw_led_idle_effect = None  # Full effect configuration dict or None
-
-        # Playing effect settings (all parameters)
-        self.dw_led_playing_effect = None  # Full effect configuration dict or None
-
-        # Idle timeout settings
+        # LED idle timeout settings (WLED idle-off; the board handles its own ring)
         self.dw_led_idle_timeout_enabled = False  # Enable automatic LED turn off after idle period
         self.dw_led_idle_timeout_minutes = 30  # Idle timeout duration in minutes
         self.dw_led_control_mode = "automated"  # "manual" or "automated"
         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"
@@ -151,14 +111,6 @@ class AppState:
         # Favicon is auto-generated from logo as logo-favicon.ico
         self.custom_logo = None  # Custom logo filename (e.g., "logo-abc123.png")
         
-        # auto_play mode settings
-        self.auto_play_enabled = False
-        self.auto_play_playlist = None  # Playlist to auto-play in auto_play mode
-        self.auto_play_run_mode = "loop"  # "single" or "loop"
-        self.auto_play_pause_time = 5.0  # Pause between patterns in seconds
-        self.auto_play_clear_pattern = "adaptive"  # Clear pattern option
-        self.auto_play_shuffle = False  # Shuffle playlist
-
         # Still Sands settings
         self.scheduled_pause_enabled = False
         self.scheduled_pause_time_slots = []  # List of time slot dictionaries
@@ -290,168 +242,6 @@ class AppState:
     def clear_pattern_speed(self, value):
         self._clear_pattern_speed = value
 
-    # --- Execution Control Properties (stop/skip with event support) ---
-
-    def _ensure_events(self):
-        """Lazily create asyncio.Event objects in the current event loop."""
-        try:
-            loop = asyncio.get_running_loop()
-        except RuntimeError:
-            # No running loop - skip event creation (sync code path)
-            return
-
-        # Recreate events if the event loop changed
-        if self._event_loop != loop:
-            self._event_loop = loop
-            self._stop_event = asyncio.Event()
-            self._skip_event = asyncio.Event()
-            # Sync event state with current flags
-            if self._stop_requested:
-                self._stop_event.set()
-            if self._skip_requested:
-                self._skip_event.set()
-
-    @property
-    def stop_requested(self) -> bool:
-        return self._stop_requested
-
-    @stop_requested.setter
-    def stop_requested(self, value: bool):
-        self._stop_requested = value
-        self._ensure_events()
-        if self._stop_event and self._event_loop:
-            # asyncio.Event.set()/clear() are NOT thread-safe
-            # Use call_soon_threadsafe when called from non-async threads (e.g., motion thread)
-            try:
-                if asyncio.get_running_loop() == self._event_loop:
-                    # Same loop - safe to call directly
-                    if value:
-                        self._stop_event.set()
-                    else:
-                        self._stop_event.clear()
-                else:
-                    # Different loop - use thread-safe call
-                    if value:
-                        self._event_loop.call_soon_threadsafe(self._stop_event.set)
-                    else:
-                        self._event_loop.call_soon_threadsafe(self._stop_event.clear)
-            except RuntimeError:
-                # No running loop (sync context) - use thread-safe call
-                if self._event_loop.is_running():
-                    if value:
-                        self._event_loop.call_soon_threadsafe(self._stop_event.set)
-                    else:
-                        self._event_loop.call_soon_threadsafe(self._stop_event.clear)
-
-    @property
-    def skip_requested(self) -> bool:
-        return self._skip_requested
-
-    @skip_requested.setter
-    def skip_requested(self, value: bool):
-        self._skip_requested = value
-        self._ensure_events()
-        if self._skip_event and self._event_loop:
-            # asyncio.Event.set()/clear() are NOT thread-safe
-            # Use call_soon_threadsafe when called from non-async threads (e.g., motion thread)
-            try:
-                if asyncio.get_running_loop() == self._event_loop:
-                    # Same loop - safe to call directly
-                    if value:
-                        self._skip_event.set()
-                    else:
-                        self._skip_event.clear()
-                else:
-                    # Different loop - use thread-safe call
-                    if value:
-                        self._event_loop.call_soon_threadsafe(self._skip_event.set)
-                    else:
-                        self._event_loop.call_soon_threadsafe(self._skip_event.clear)
-            except RuntimeError:
-                # No running loop (sync context) - use thread-safe call
-                if self._event_loop.is_running():
-                    if value:
-                        self._event_loop.call_soon_threadsafe(self._skip_event.set)
-                    else:
-                        self._event_loop.call_soon_threadsafe(self._skip_event.clear)
-
-    def get_stop_event(self) -> Optional[asyncio.Event]:
-        """Get the stop event for async waiting. Returns None if no event loop."""
-        self._ensure_events()
-        return self._stop_event
-
-    def get_skip_event(self) -> Optional[asyncio.Event]:
-        """Get the skip event for async waiting. Returns None if no event loop."""
-        self._ensure_events()
-        return self._skip_event
-
-    async def wait_for_interrupt(
-        self,
-        timeout: float = 1.0,
-        check_stop: bool = True,
-        check_skip: bool = True,
-    ) -> Literal['timeout', 'stopped', 'skipped']:
-        """
-        Wait for a stop/skip interrupt or timeout.
-
-        This provides instant response to stop/skip requests by waiting on
-        asyncio.Event objects rather than polling flags.
-
-        Args:
-            timeout: Maximum time to wait in seconds
-            check_stop: Whether to check for stop requests
-            check_skip: Whether to check for skip requests
-
-        Returns:
-            'stopped' if stop was requested
-            'skipped' if skip was requested
-            'timeout' if timeout elapsed without interrupt
-        """
-        # Quick flag check first (handles edge cases and sync code)
-        if check_stop and self._stop_requested:
-            return 'stopped'
-        if check_skip and self._skip_requested:
-            return 'skipped'
-
-        self._ensure_events()
-
-        # Build list of event wait tasks
-        tasks = []
-        if check_stop and self._stop_event:
-            tasks.append(asyncio.create_task(self._stop_event.wait(), name='stop'))
-        if check_skip and self._skip_event:
-            tasks.append(asyncio.create_task(self._skip_event.wait(), name='skip'))
-
-        if not tasks:
-            # No events available, fall back to simple sleep
-            await asyncio.sleep(timeout)
-            return 'timeout'
-
-        # Add timeout task
-        timeout_task = asyncio.create_task(asyncio.sleep(timeout), name='timeout')
-        tasks.append(timeout_task)
-
-        pending = set()  # Initialize to empty set to avoid UnboundLocalError
-        try:
-            done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
-        finally:
-            # Cancel all pending tasks
-            for task in pending:
-                task.cancel()
-            # Await cancelled tasks to suppress warnings
-            for task in pending:
-                try:
-                    await task
-                except asyncio.CancelledError:
-                    pass
-
-        # Check which event fired (flags are authoritative)
-        if check_stop and self._stop_requested:
-            return 'stopped'
-        if check_skip and self._skip_requested:
-            return 'skipped'
-        return 'timeout'
-
     @property
     def led_automation_enabled(self) -> bool:
         return self.dw_led_control_mode == "automated"
@@ -467,22 +257,13 @@ class AppState:
     def to_state_dict(self):
         """Return a dictionary of runtime/machine state (transient data)."""
         return {
-            "stop_requested": self.stop_requested,
             "pause_requested": self._pause_requested,
             "current_playing_file": self._current_playing_file,
             "current_playlist": self._current_playlist,
             "current_playlist_name": self._current_playlist_name,
-            "current_playlist_index": self.current_playlist_index,
-            "execution_progress": self.execution_progress,
-            "is_clearing": self.is_clearing,
             "current_theta": self.current_theta,
             "current_rho": self.current_rho,
-            "machine_x": self.machine_x,
-            "machine_y": self.machine_y,
-            "x_steps_per_mm": self.x_steps_per_mm,
-            "y_steps_per_mm": self.y_steps_per_mm,
             "port": self.port,
-            "patterns_since_last_home": self.patterns_since_last_home,
         }
 
     def to_settings_dict(self):
@@ -494,7 +275,6 @@ class AppState:
 
         return {
             "speed": self._speed,
-            "gear_ratio": self.gear_ratio,
             "homing": self.homing,
             "homing_user_override": self.homing_user_override,
             "angular_homing_offset_degrees": self.angular_homing_offset_degrees,
@@ -509,18 +289,9 @@ class AppState:
             "shuffle": self._shuffle,
             "custom_clear_from_in": self.custom_clear_from_in,
             "custom_clear_from_out": self.custom_clear_from_out,
-            "preferred_port": self.preferred_port,
             "board_url": self.board_url,
             "wled_ip": self.wled_ip,
             "led_provider": self.led_provider,
-            "dw_led_num_leds": self.dw_led_num_leds,
-            "dw_led_gpio_pin": self.dw_led_gpio_pin,
-            "dw_led_pixel_order": self.dw_led_pixel_order,
-            "dw_led_brightness": self.dw_led_brightness,
-            "dw_led_speed": self.dw_led_speed,
-            "dw_led_intensity": self.dw_led_intensity,
-            "dw_led_idle_effect": self.dw_led_idle_effect,
-            "dw_led_playing_effect": self.dw_led_playing_effect,
             "dw_led_idle_timeout_enabled": self.dw_led_idle_timeout_enabled,
             "dw_led_idle_timeout_minutes": self.dw_led_idle_timeout_minutes,
             "dw_led_control_mode": self.dw_led_control_mode,
@@ -529,12 +300,6 @@ class AppState:
             "table_name": self.table_name,
             "known_tables": self.known_tables,
             "custom_logo": self.custom_logo,
-            "auto_play_enabled": self.auto_play_enabled,
-            "auto_play_playlist": self.auto_play_playlist,
-            "auto_play_run_mode": self.auto_play_run_mode,
-            "auto_play_pause_time": self.auto_play_pause_time,
-            "auto_play_clear_pattern": self.auto_play_clear_pattern,
-            "auto_play_shuffle": self.auto_play_shuffle,
             "scheduled_pause_enabled": self.scheduled_pause_enabled,
             "scheduled_pause_time_slots": self.scheduled_pause_time_slots,
             "scheduled_pause_control_wled": self.scheduled_pause_control_wled,
@@ -551,8 +316,6 @@ class AppState:
             "mqtt_discovery_prefix": self.mqtt_discovery_prefix,
             "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,
         }
@@ -565,22 +328,13 @@ class AppState:
 
     def from_state_dict(self, data):
         """Update runtime state from a dictionary."""
-        self.stop_requested = data.get("stop_requested", False)
         self._pause_requested = data.get("pause_requested", False)
         self._current_playing_file = data.get("current_playing_file", None)
         self._current_playlist = data.get("current_playlist", None)
         self._current_playlist_name = data.get("current_playlist_name", None)
-        self.current_playlist_index = data.get("current_playlist_index", None)
-        self.execution_progress = data.get("execution_progress")
-        self.is_clearing = data.get("is_clearing", False)
         self.current_theta = data.get("current_theta", 0)
         self.current_rho = data.get("current_rho", 0)
-        self.machine_x = data.get("machine_x", 0.0)
-        self.machine_y = data.get("machine_y", 0.0)
-        self.x_steps_per_mm = data.get("x_steps_per_mm", 0.0)
-        self.y_steps_per_mm = data.get("y_steps_per_mm", 0.0)
         self.port = data.get("port", None)
-        self.patterns_since_last_home = data.get("patterns_since_last_home", 0)
 
     @staticmethod
     def _decode_mqtt_password(stored_value):
@@ -601,7 +355,6 @@ class AppState:
     def from_settings_dict(self, data):
         """Update user settings from a dictionary."""
         self._speed = data.get("speed", 150)
-        self.gear_ratio = data.get('gear_ratio', 10)
         self.homing = data.get('homing', 0)
         self.homing_user_override = data.get('homing_user_override', False)
         self.angular_homing_offset_degrees = data.get('angular_homing_offset_degrees', 0.0)
@@ -616,29 +369,9 @@ class AppState:
         self._shuffle = data.get("shuffle", False)
         self.custom_clear_from_in = data.get("custom_clear_from_in", None)
         self.custom_clear_from_out = data.get("custom_clear_from_out", None)
-        self.preferred_port = data.get("preferred_port", None)
         self.board_url = data.get("board_url", None)
         self.wled_ip = data.get('wled_ip', None)
         self.led_provider = data.get('led_provider', "none")
-        self.dw_led_num_leds = data.get('dw_led_num_leds', 60)
-        self.dw_led_gpio_pin = data.get('dw_led_gpio_pin', 18)
-        self.dw_led_pixel_order = data.get('dw_led_pixel_order', "RGB")
-        self.dw_led_brightness = data.get('dw_led_brightness', 35)
-        self.dw_led_speed = data.get('dw_led_speed', 50)
-        self.dw_led_intensity = data.get('dw_led_intensity', 128)
-
-        # Load effect settings (handle both old string format and new dict format)
-        idle_effect_data = data.get('dw_led_idle_effect', None)
-        if isinstance(idle_effect_data, str):
-            self.dw_led_idle_effect = None if idle_effect_data == "off" else {"effect_id": 0}
-        else:
-            self.dw_led_idle_effect = idle_effect_data
-
-        playing_effect_data = data.get('dw_led_playing_effect', None)
-        if isinstance(playing_effect_data, str):
-            self.dw_led_playing_effect = None if playing_effect_data == "off" else {"effect_id": 0}
-        else:
-            self.dw_led_playing_effect = playing_effect_data
 
         self.dw_led_idle_timeout_enabled = data.get('dw_led_idle_timeout_enabled', False)
         self.dw_led_idle_timeout_minutes = data.get('dw_led_idle_timeout_minutes', 30)
@@ -651,12 +384,6 @@ class AppState:
         self.table_name = data.get("table_name", "Dune Weaver")
         self.known_tables = data.get("known_tables", [])
         self.custom_logo = data.get("custom_logo", None)
-        self.auto_play_enabled = data.get("auto_play_enabled", False)
-        self.auto_play_playlist = data.get("auto_play_playlist", None)
-        self.auto_play_run_mode = data.get("auto_play_run_mode", "loop")
-        self.auto_play_pause_time = data.get("auto_play_pause_time", 5.0)
-        self.auto_play_clear_pattern = data.get("auto_play_clear_pattern", "adaptive")
-        self.auto_play_shuffle = data.get("auto_play_shuffle", False)
         self.scheduled_pause_enabled = data.get("scheduled_pause_enabled", False)
         self.scheduled_pause_time_slots = data.get("scheduled_pause_time_slots", [])
         self.scheduled_pause_control_wled = data.get("scheduled_pause_control_wled", False)
@@ -677,8 +404,6 @@ class AppState:
         self.mqtt_discovery_prefix = data.get("mqtt_discovery_prefix", "homeassistant")
         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", "")
 

+ 241 - 0
modules/led/board_led_controller.py

@@ -0,0 +1,241 @@
+"""
+Board LED controller — drives the table's own LED ring through the FluidNC
+firmware instead of host GPIO.
+
+The firmware owns the strip ($LED/* NVS settings, live control via /sand_led)
+and natively handles run/idle transitions ($LED/RunEffect / $LED/IdleEffect),
+so the host neither renders effects nor switches them around pattern playback.
+
+This class intentionally mirrors the DWLEDController surface the /api/dw_leds/*
+endpoints are duck-typed against (check_status, set_power, set_brightness,
+set_color(s), get_effects/get_palettes, set_effect/set_palette, set_speed,
+set_intensity), so the existing LED page works unchanged against the board.
+"""
+import logging
+from typing import Dict, List, Optional, Tuple
+
+logger = logging.getLogger(__name__)
+
+# Firmware effects in the firmware's order (ids are list indices).
+# Keep in sync with the firmware's Leds.cpp / the mobile app's LED_EFFECTS.
+BOARD_EFFECTS: List[Tuple[str, str]] = [
+    ("off", "Off"), ("static", "Static"), ("rainbow", "Rainbow"),
+    ("breathe", "Breathe"), ("colorloop", "Color loop"), ("theater", "Theater"),
+    ("scan", "Scan"), ("running", "Running"), ("sine", "Sine"),
+    ("gradient", "Gradient"), ("sinelon", "Sinelon"), ("twinkle", "Twinkle"),
+    ("sparkle", "Sparkle"), ("fire", "Fire"), ("candle", "Candle"),
+    ("meteor", "Meteor"), ("bouncing", "Bouncing"), ("wipe", "Wipe"),
+    ("dualscan", "Dual scan"), ("juggle", "Juggle"), ("multicomet", "Multi-comet"),
+    ("glitter", "Glitter"), ("dissolve", "Dissolve"), ("ripple", "Ripple"),
+    ("drip", "Drip"), ("lightning", "Lightning"), ("fireworks", "Fireworks"),
+    ("plasma", "Plasma"), ("heartbeat", "Heartbeat"), ("strobe", "Strobe"),
+    ("police", "Police"), ("chase", "Chase"), ("railway", "Railway"),
+    ("pacifica", "Pacifica"), ("aurora", "Aurora"), ("pride", "Pride"),
+    ("colorwaves", "Color waves"), ("bpm", "BPM"), ("ball", "Ball"),
+]
+BOARD_PALETTES = ["rainbow", "ocean", "lava", "forest", "party", "cloud", "heat", "sunset"]
+
+_EFFECT_ID_BY_NAME = {name: i for i, (name, _label) in enumerate(BOARD_EFFECTS)}
+
+
+def effect_name_for_id(effect_id: int) -> Optional[str]:
+    if 0 <= effect_id < len(BOARD_EFFECTS):
+        return BOARD_EFFECTS[effect_id][0]
+    return None
+
+
+def effect_id_for_name(name: str) -> int:
+    return _EFFECT_ID_BY_NAME.get((name or "").lower(), 0)
+
+
+class BoardLEDController:
+    """DWLEDController-compatible facade over the firmware's LED API."""
+
+    def __init__(self):
+        # Restore target for set_power(1) when the strip was turned off.
+        self._last_effect = "static"
+
+    # -- helpers ---------------------------------------------------------
+
+    def _conn(self):
+        from modules.core.state import state
+        if not state.conn or not state.conn.is_connected():
+            return None
+        return state.conn
+
+    def _led(self, **keys) -> Dict:
+        """Send live LED control (/sand_led); works mid-pattern, NVS-persisted at idle."""
+        conn = self._conn()
+        if not conn:
+            return {"connected": False, "error": "Table not connected"}
+        try:
+            conn.set_led(**keys)
+            return {"connected": True, "power_on": keys.get("effect") != "off"}
+        except Exception as e:
+            logger.warning(f"Board LED control failed ({keys}): {e}")
+            return {"connected": False, "error": str(e)}
+
+    def _read(self) -> Optional[Dict]:
+        """Read the board's $LED/* settings map (None when unreachable)."""
+        conn = self._conn()
+        if not conn:
+            return None
+        try:
+            settings = conn.get_settings()
+            return {k[4:]: v for k, v in settings.items() if k.startswith("LED/")}
+        except Exception as e:
+            logger.warning(f"Could not read board LED settings: {e}")
+            return None
+
+    # -- DWLEDController-compatible surface -------------------------------
+
+    def check_status(self) -> Dict:
+        led = self._read()
+        if led is None:
+            return {"connected": False, "error": "Table not connected"}
+        effect = (led.get("Effect") or "off").lower()
+        if effect != "off":
+            self._last_effect = effect
+        brightness_255 = int(led.get("Brightness", 128) or 0)
+        palette = (led.get("Palette") or "rainbow").lower()
+        return {
+            "connected": True,
+            "power_on": effect != "off",
+            "brightness": round(brightness_255 * 100 / 255),
+            "speed": int(led.get("Speed", 128) or 0),
+            "intensity": 0,  # no firmware equivalent
+            "current_effect": effect_id_for_name(effect),
+            "current_palette": BOARD_PALETTES.index(palette) if palette in BOARD_PALETTES else 0,
+            "num_leds": 0,  # owned by the board's config.yaml
+            "gpio_pin": None,
+            "colors": [
+                f"#{led.get('Color', 'FF0000')}",
+                f"#{led.get('Color2', '000000')}",
+                "#000000",
+            ],
+            "run_effect": (led.get("RunEffect") or "none").lower(),
+            "idle_effect": (led.get("IdleEffect") or "none").lower(),
+            # 'ball' effect params (firmware-native; the blob that follows the ball).
+            "ball": {
+                "fgbright": int(led.get("BallBright", 255) or 0),
+                "bgbright": int(led.get("BallBgBright", 255) or 0),
+                "size": int(led.get("BallSize", 3) or 0),
+                "bg": (led.get("BallBg") or "static").lower(),
+                "direction": (led.get("Direction") or "cw").lower(),
+                "align": int(led.get("Align", 0) or 0),
+            },
+        }
+
+    def set_power(self, power_state: int) -> Dict:
+        status = self.check_status()
+        if not status.get("connected"):
+            return status
+        currently_on = status.get("power_on", False)
+        turn_on = not currently_on if power_state == 2 else bool(power_state)
+        if turn_on == currently_on:
+            return status
+        result = self._led(effect=self._last_effect if turn_on else "off")
+        result["power_on"] = turn_on
+        return result
+
+    def set_brightness(self, value: int) -> Dict:
+        # dw API is 0-100; the firmware wants 0-255.
+        return self._led(brightness=max(0, min(255, round(value * 255 / 100))))
+
+    def set_color(self, r: int, g: int, b: int) -> Dict:
+        return self._led(color=f"{r:02X}{g:02X}{b:02X}")
+
+    def set_colors(self, color1=None, color2=None, color3=None) -> Dict:
+        keys = {}
+        if color1:
+            keys["color"] = "{:02X}{:02X}{:02X}".format(*color1)
+        if color2:
+            keys["color2"] = "{:02X}{:02X}{:02X}".format(*color2)
+        # color3 has no firmware equivalent
+        if not keys:
+            return {"connected": True}
+        return self._led(**keys)
+
+    def get_effects(self) -> List[Tuple[int, str]]:
+        return [(i, label) for i, (_name, label) in enumerate(BOARD_EFFECTS)]
+
+    def get_palettes(self) -> List[Tuple[int, str]]:
+        return [(i, name.capitalize()) for i, name in enumerate(BOARD_PALETTES)]
+
+    def set_effect(self, effect_id: int, speed: Optional[int] = None,
+                   intensity: Optional[int] = None) -> Dict:
+        name = effect_name_for_id(int(effect_id))
+        if name is None:
+            return {"connected": False, "error": f"Unknown effect id {effect_id}"}
+        if name != "off":
+            self._last_effect = name
+        keys = {"effect": name}
+        if speed is not None:
+            keys["speed"] = max(1, min(255, int(speed)))
+        return self._led(**keys)
+
+    def set_palette(self, palette_id: int) -> Dict:
+        if not 0 <= int(palette_id) < len(BOARD_PALETTES):
+            return {"connected": False, "error": f"Unknown palette id {palette_id}"}
+        return self._led(palette=BOARD_PALETTES[int(palette_id)])
+
+    def set_speed(self, value: int) -> Dict:
+        return self._led(speed=max(1, min(255, int(value))))
+
+    def set_intensity(self, value: int) -> Dict:
+        # The firmware has no intensity control; accept and ignore.
+        return {"connected": True}
+
+    # -- 'ball' tracker (firmware-native blob that follows the sand ball) -----
+
+    # Live /sand_led keys, with their clamp ranges (persisted to NVS at idle).
+    _BALL_KEYS = {
+        "fgbright": (0, 255),   # blob brightness
+        "bgbright": (0, 255),   # background brightness
+        "size": (1, 200),       # glow size in LEDs
+        "align": (0, 359),      # rotate the blob onto the ball (degrees)
+    }
+
+    def set_ball(self, **params) -> Dict:
+        """Tune the 'ball' effect live via /sand_led (only meaningful while the
+        'ball' effect is active). Accepted keys: fgbright, bgbright, size, align
+        (ints), direction ('cw'|'ccw'), bg (sub-effect name / 'static' / 'off'),
+        color, color2 (RRGGBB hex)."""
+        keys: dict = {}
+        for key, (lo, hi) in self._BALL_KEYS.items():
+            if params.get(key) is not None:
+                keys[key] = max(lo, min(hi, int(params[key])))
+        if params.get("direction") in ("cw", "ccw"):
+            keys["direction"] = params["direction"]
+        if params.get("bg"):
+            keys["bg"] = str(params["bg"])
+        for c in ("color", "color2"):
+            if params.get(c):
+                keys[c] = str(params[c]).lstrip("#").upper()
+        if not keys:
+            return {"connected": True}
+        return self._led(**keys)
+
+    # -- firmware-native run/idle automation -------------------------------
+
+    def set_run_effect(self, effect_name: str) -> bool:
+        """$LED/RunEffect — the board switches to it while moving ('none' disables)."""
+        return self._set_auto_effect("LED/RunEffect", effect_name)
+
+    def set_idle_effect(self, effect_name: str) -> bool:
+        """$LED/IdleEffect — the board switches to it at idle ('none' disables)."""
+        return self._set_auto_effect("LED/IdleEffect", effect_name)
+
+    def _set_auto_effect(self, key: str, effect_name: str) -> bool:
+        conn = self._conn()
+        if not conn:
+            return False
+        try:
+            conn.set_setting(key, effect_name or "none")
+            return True
+        except Exception as e:
+            logger.warning(f"Could not set {key}={effect_name}: {e}")
+            return False
+
+    def stop(self):
+        """Provider-switch hook; nothing to tear down (the board keeps running)."""

+ 0 - 659
modules/led/dw_led_controller.py

@@ -1,659 +0,0 @@
-"""
-Dune Weaver LED Controller - Embedded NeoPixel LED controller for Raspberry Pi
-Provides direct GPIO control of WS2812B LED strips with beautiful effects
-"""
-import threading
-import time
-import logging
-from typing import Optional, Dict, List, Tuple
-from .dw_leds.segment import Segment
-from .dw_leds.effects.basic_effects import get_effect, get_all_effects
-from .dw_leds.utils.palettes import get_palette_name, PALETTE_NAMES
-from .dw_leds.utils.colors import rgb_to_color
-
-logger = logging.getLogger(__name__)
-
-
-class DWLEDController:
-    """Dune Weaver LED Controller for NeoPixel LED strips"""
-
-    def __init__(self, num_leds: int = 60, gpio_pin: int = 18, brightness: float = 0.35,
-                 pixel_order: str = "GRB", speed: int = 128, intensity: int = 128):
-        """
-        Initialize Dune Weaver LED controller
-
-        Args:
-            num_leds: Number of LEDs in the strip
-            gpio_pin: GPIO pin number (BCM numbering: 12, 13, 18, or 19)
-            brightness: Global brightness (0.0 - 1.0)
-            pixel_order: Pixel color order (GRB, RGB, RGBW, GRBW)
-            speed: Effect speed 0-255 (default: 128)
-            intensity: Effect intensity 0-255 (default: 128)
-        """
-        self.num_leds = num_leds
-        self.gpio_pin = gpio_pin
-        self.brightness = brightness
-        self.pixel_order = pixel_order
-        self.is_rgbw = 'W' in pixel_order.upper()
-        self._off_color = (0, 0, 0, 0) if self.is_rgbw else (0, 0, 0)
-
-        # State
-        self._powered_on = False
-        self._current_effect_id = 8
-        self._current_palette_id = 0
-        self._speed = speed
-        self._intensity = intensity
-        self._color1 = (255, 0, 0)  # Red (primary)
-        self._color2 = (0, 0, 0)  # Black (background/off)
-        self._color3 = (0, 0, 255)  # Blue (tertiary)
-
-        # Threading
-        self._pixels = None
-        self._segment = None
-        self._effect_thread = None
-        self._stop_thread = threading.Event()
-        self._lock = threading.Lock()
-        self._initialized = False
-        self._init_error = None  # Store initialization error message
-
-    def _initialize_hardware(self):
-        """Lazy initialization of NeoPixel hardware"""
-        if self._initialized:
-            return True
-
-        # Try standard NeoPixel library first (works on Pi 4 and earlier)
-        # If that fails, fall back to Pi 5-specific library
-        neopixel_module = None
-        using_pi5_library = False
-
-        try:
-            import board
-            import neopixel
-            neopixel_module = neopixel
-            logger.info("Using standard NeoPixel library")
-        except (ImportError, RuntimeError) as e:
-            logger.warning(f"Standard NeoPixel library failed: {e}. Trying Pi 5 library...")
-            try:
-                import board
-                from adafruit_blinka_raspberry_pi5_neopixel import neopixel as neopixel_pi5
-                neopixel_module = neopixel_pi5
-                using_pi5_library = True
-                logger.info("Using Adafruit Pi 5 NeoPixel library (PIO-based)")
-            except ImportError as e2:
-                error_msg = (
-                    f"Failed to import NeoPixel libraries. "
-                    f"Standard library error: {e}. "
-                    f"Pi 5 library error: {e2}. "
-                    f"For Pi 4 and earlier: pip install adafruit-circuitpython-neopixel adafruit-blinka. "
-                    f"For Pi 5: pip install Adafruit-Blinka-Raspberry-Pi5-Neopixel"
-                )
-                self._init_error = error_msg
-                logger.error(error_msg)
-                return False
-
-        try:
-            # Map GPIO pin numbers to board pins
-            pin_map = {
-                12: board.D12,
-                13: board.D13,
-                18: board.D18,
-                19: board.D19
-            }
-
-            if self.gpio_pin not in pin_map:
-                error_msg = f"Invalid GPIO pin {self.gpio_pin}. Must be 12, 13, 18, or 19 (PWM-capable pins)"
-                self._init_error = error_msg
-                logger.error(error_msg)
-                return False
-
-            board_pin = pin_map[self.gpio_pin]
-
-            # Initialize NeoPixel strip
-            self._pixels = neopixel_module.NeoPixel(
-                board_pin,
-                self.num_leds,
-                brightness=self.brightness,
-                auto_write=False,
-                pixel_order=self.pixel_order
-            )
-
-            # Create segment for the entire strip
-            self._segment = Segment(self._pixels, 0, self.num_leds, is_rgbw=self.is_rgbw)
-            self._segment.speed = self._speed
-            self._segment.intensity = self._intensity
-            self._segment.palette_id = self._current_palette_id
-
-            # Set colors
-            self._segment.colors[0] = rgb_to_color(*self._color1)
-            self._segment.colors[1] = rgb_to_color(*self._color2)
-            self._segment.colors[2] = rgb_to_color(*self._color3)
-
-            self._initialized = True
-            library_type = "Pi 5 (PIO)" if using_pi5_library else "standard"
-            logger.info(f"DW LEDs initialized: {self.num_leds} LEDs on GPIO {self.gpio_pin} using {library_type} library")
-            return True
-
-        except Exception as e:
-            error_msg = f"Failed to initialize NeoPixel hardware: {e}"
-            self._init_error = error_msg
-            logger.error(error_msg)
-            return False
-
-    def _effect_loop(self):
-        """Background thread that runs the current effect"""
-        while not self._stop_thread.is_set():
-            try:
-                with self._lock:
-                    if self._pixels and self._segment and self._powered_on:
-                        # Get current effect function (allows dynamic effect switching)
-                        effect_func = get_effect(self._current_effect_id)
-
-                        # Run effect and get delay
-                        delay_ms = effect_func(self._segment)
-
-                        # Update pixels
-                        self._pixels.show()
-
-                        # Increment call counter
-                        self._segment.call += 1
-                    else:
-                        delay_ms = 100  # Idle delay when off
-
-                # Sleep for the effect's requested delay
-                time.sleep(delay_ms / 1000.0)
-
-            except Exception as e:
-                logger.error(f"Error in effect loop: {e}")
-                time.sleep(0.1)
-
-    def set_power(self, state: int) -> Dict:
-        """
-        Set power state
-
-        Args:
-            state: 0=Off, 1=On, 2=Toggle
-
-        Returns:
-            Dict with status
-        """
-        if not self._initialize_hardware():
-            return {
-                "connected": False,
-                "error": self._init_error or "Failed to initialize LED hardware"
-            }
-
-        with self._lock:
-            if state == 2:  # Toggle
-                self._powered_on = not self._powered_on
-            else:
-                self._powered_on = bool(state)
-
-            # Turn off all pixels immediately when powering off
-            if not self._powered_on and self._pixels:
-                self._pixels.fill(self._off_color)
-                self._pixels.show()
-
-            # Start effect thread if not running
-            if self._powered_on and (self._effect_thread is None or not self._effect_thread.is_alive()):
-                self._stop_thread.clear()
-                self._effect_thread = threading.Thread(target=self._effect_loop, daemon=True)
-                self._effect_thread.start()
-
-        return {
-            "connected": True,
-            "power_on": self._powered_on,
-            "message": f"Power {'on' if self._powered_on else 'off'}"
-        }
-
-    def set_brightness(self, value: int) -> Dict:
-        """
-        Set global brightness
-
-        Args:
-            value: Brightness 0-100
-
-        Returns:
-            Dict with status
-        """
-        if not self._initialized:
-            if not self._initialize_hardware():
-                return {"connected": False, "error": self._init_error or "Hardware not initialized"}
-
-        brightness = max(0.0, min(1.0, value / 100.0))
-
-        with self._lock:
-            self.brightness = brightness
-            if self._pixels:
-                self._pixels.brightness = brightness
-
-        return {
-            "connected": True,
-            "brightness": int(brightness * 100),
-            "message": "Brightness updated"
-        }
-
-    def set_color(self, r: int, g: int, b: int) -> Dict:
-        """
-        Set solid color (sets effect to Static and color1)
-
-        Args:
-            r, g, b: RGB values 0-255
-
-        Returns:
-            Dict with status
-        """
-        if not self._initialized:
-            if not self._initialize_hardware():
-                return {"connected": False, "error": self._init_error or "Hardware not initialized"}
-
-        with self._lock:
-            self._color1 = (r, g, b)
-            if self._segment:
-                self._segment.colors[0] = rgb_to_color(r, g, b)
-                # Switch to static effect
-                self._current_effect_id = 0
-                self._segment.reset()
-
-            # Auto power on when setting color
-            if not self._powered_on:
-                self._powered_on = True
-
-            # Ensure effect thread is running
-            if self._effect_thread is None or not self._effect_thread.is_alive():
-                self._stop_thread.clear()
-                self._effect_thread = threading.Thread(target=self._effect_loop, daemon=True)
-                self._effect_thread.start()
-
-        return {
-            "connected": True,
-            "color": [r, g, b],
-            "power_on": self._powered_on,
-            "message": "Color set"
-        }
-
-    def set_colors(self, color1: Optional[Tuple[int, int, int]] = None,
-                   color2: Optional[Tuple[int, int, int]] = None,
-                   color3: Optional[Tuple[int, int, int]] = None) -> Dict:
-        """
-        Set effect colors (does not change effect or auto-power on)
-
-        Args:
-            color1: Primary color RGB tuple (0-255)
-            color2: Secondary/background color RGB tuple (0-255)
-            color3: Tertiary color RGB tuple (0-255)
-
-        Returns:
-            Dict with status
-        """
-        if not self._initialized:
-            if not self._initialize_hardware():
-                return {"connected": False, "error": self._init_error or "Hardware not initialized"}
-
-        colors_set = []
-        with self._lock:
-            if color1 is not None:
-                self._color1 = color1
-                if self._segment:
-                    self._segment.colors[0] = rgb_to_color(*color1)
-                colors_set.append(f"color1={color1}")
-
-            if color2 is not None:
-                self._color2 = color2
-                if self._segment:
-                    self._segment.colors[1] = rgb_to_color(*color2)
-                colors_set.append(f"color2={color2}")
-
-            if color3 is not None:
-                self._color3 = color3
-                if self._segment:
-                    self._segment.colors[2] = rgb_to_color(*color3)
-                colors_set.append(f"color3={color3}")
-
-            # Reset effect to apply new colors
-            if self._segment and colors_set:
-                self._segment.reset()
-
-        return {
-            "connected": True,
-            "colors": {
-                "color1": self._color1,
-                "color2": self._color2,
-                "color3": self._color3
-            },
-            "message": f"Colors updated: {', '.join(colors_set)}"
-        }
-
-    def set_effect(self, effect_id: int, speed: Optional[int] = None,
-                   intensity: Optional[int] = None) -> Dict:
-        """
-        Set active effect
-
-        Args:
-            effect_id: Effect ID (0-15)
-            speed: Optional speed override (0-255)
-            intensity: Optional intensity override (0-255)
-
-        Returns:
-            Dict with status
-        """
-        if not self._initialized:
-            if not self._initialize_hardware():
-                return {"connected": False, "error": self._init_error or "Hardware not initialized"}
-
-        # Validate effect ID
-        effects = get_all_effects()
-        if not any(eid == effect_id for eid, _ in effects):
-            return {
-                "connected": False,
-                "message": f"Invalid effect ID: {effect_id}"
-            }
-
-        with self._lock:
-            self._current_effect_id = effect_id
-
-            if speed is not None:
-                self._speed = max(0, min(255, speed))
-                if self._segment:
-                    self._segment.speed = self._speed
-
-            if intensity is not None:
-                self._intensity = max(0, min(255, intensity))
-                if self._segment:
-                    self._segment.intensity = self._intensity
-
-            # Reset effect state
-            if self._segment:
-                self._segment.reset()
-
-            # Auto power on when setting effect
-            if not self._powered_on:
-                self._powered_on = True
-
-            # Ensure effect thread is running
-            if self._effect_thread is None or not self._effect_thread.is_alive():
-                self._stop_thread.clear()
-                self._effect_thread = threading.Thread(target=self._effect_loop, daemon=True)
-                self._effect_thread.start()
-
-        effect_name = next(name for eid, name in effects if eid == effect_id)
-        return {
-            "connected": True,
-            "effect_id": effect_id,
-            "effect_name": effect_name,
-            "power_on": self._powered_on,
-            "message": f"Effect set to {effect_name}"
-        }
-
-    def set_palette(self, palette_id: int) -> Dict:
-        """
-        Set color palette
-
-        Args:
-            palette_id: Palette ID (0-58)
-
-        Returns:
-            Dict with status
-        """
-        if not self._initialized:
-            if not self._initialize_hardware():
-                return {"connected": False, "error": self._init_error or "Hardware not initialized"}
-
-        if palette_id < 0 or palette_id >= len(PALETTE_NAMES):
-            return {
-                "connected": False,
-                "message": f"Invalid palette ID: {palette_id}"
-            }
-
-        with self._lock:
-            self._current_palette_id = palette_id
-            if self._segment:
-                self._segment.palette_id = palette_id
-
-            # Auto power on when setting palette
-            if not self._powered_on:
-                self._powered_on = True
-
-            # Ensure effect thread is running
-            if self._effect_thread is None or not self._effect_thread.is_alive():
-                self._stop_thread.clear()
-                self._effect_thread = threading.Thread(target=self._effect_loop, daemon=True)
-                self._effect_thread.start()
-
-        palette_name = get_palette_name(palette_id)
-        return {
-            "connected": True,
-            "palette_id": palette_id,
-            "palette_name": palette_name,
-            "power_on": self._powered_on,
-            "message": f"Palette set to {palette_name}"
-        }
-
-    def set_speed(self, speed: int) -> Dict:
-        """Set effect speed (0-255)"""
-        if not self._initialized:
-            if not self._initialize_hardware():
-                return {"connected": False, "error": self._init_error or "Hardware not initialized"}
-
-        speed = max(0, min(255, speed))
-
-        with self._lock:
-            self._speed = speed
-            if self._segment:
-                self._segment.speed = speed
-                # Reset effect state so speed change takes effect immediately
-                self._segment.reset()
-
-        return {
-            "connected": True,
-            "speed": speed,
-            "message": "Speed updated"
-        }
-
-    def set_intensity(self, intensity: int) -> Dict:
-        """Set effect intensity (0-255)"""
-        if not self._initialized:
-            if not self._initialize_hardware():
-                return {"connected": False, "error": self._init_error or "Hardware not initialized"}
-
-        intensity = max(0, min(255, intensity))
-
-        with self._lock:
-            self._intensity = intensity
-            if self._segment:
-                self._segment.intensity = intensity
-                # Reset effect state so intensity change takes effect immediately
-                self._segment.reset()
-
-        return {
-            "connected": True,
-            "intensity": intensity,
-            "message": "Intensity updated"
-        }
-
-    def get_effects(self) -> List[Tuple[int, str]]:
-        """Get list of all available effects"""
-        return get_all_effects()
-
-    def get_palettes(self) -> List[Tuple[int, str]]:
-        """Get list of all available palettes"""
-        return [(i, name) for i, name in enumerate(PALETTE_NAMES)]
-
-    def check_status(self) -> Dict:
-        """Get current controller status"""
-        # Attempt initialization if not already initialized
-        if not self._initialized:
-            self._initialize_hardware()
-
-        # Get color slots from segment if available
-        colors = []
-        if self._segment and hasattr(self._segment, 'colors'):
-            for color_int in self._segment.colors[:3]:  # Get up to 3 colors
-                # Convert integer color to hex string
-                r = (color_int >> 16) & 0xFF
-                g = (color_int >> 8) & 0xFF
-                b = color_int & 0xFF
-                colors.append(f"#{r:02x}{g:02x}{b:02x}")
-        else:
-            colors = ["#ff0000", "#000000", "#0000ff"]  # Defaults
-
-        status = {
-            "connected": self._initialized,
-            "power_on": self._powered_on,
-            "num_leds": self.num_leds,
-            "gpio_pin": self.gpio_pin,
-            "brightness": int(self.brightness * 100),
-            "current_effect": self._current_effect_id,
-            "current_palette": self._current_palette_id,
-            "speed": self._speed,
-            "intensity": self._intensity,
-            "colors": colors,
-            "effect_running": self._effect_thread is not None and self._effect_thread.is_alive()
-        }
-
-        # Include error message if not initialized
-        if not self._initialized and self._init_error:
-            status["error"] = self._init_error
-
-        return status
-
-    def stop(self):
-        """Stop the effect loop and cleanup"""
-        self._stop_thread.set()
-        if self._effect_thread and self._effect_thread.is_alive():
-            self._effect_thread.join(timeout=1.0)
-
-        with self._lock:
-            if self._pixels:
-                self._pixels.fill(self._off_color)
-                self._pixels.show()
-                self._pixels.deinit()
-            self._pixels = None
-            self._segment = None
-            self._initialized = False
-
-
-# Helper functions for pattern manager integration
-def effect_loading(controller: DWLEDController) -> bool:
-    """Show loading effect (Rainbow Cycle)"""
-    try:
-        controller.set_power(1)
-        controller.set_effect(8, speed=100)  # Rainbow Cycle
-        return True
-    except Exception as e:
-        logger.error(f"Error setting loading effect: {e}")
-        return False
-
-
-def effect_idle(controller: DWLEDController, effect_settings: Optional[dict] = None) -> bool:
-    """Show idle effect with full settings. If no effect configured, plays Rainbow with current parameters."""
-    try:
-        controller.set_power(1)
-
-        if effect_settings and isinstance(effect_settings, dict):
-            # Configured idle effect: apply full settings
-            effect_id = effect_settings.get("effect_id", 0)
-            palette_id = effect_settings.get("palette_id", 0)
-            speed = effect_settings.get("speed", 128)
-            intensity = effect_settings.get("intensity", 128)
-
-            controller.set_effect(effect_id, speed=speed, intensity=intensity)
-            controller.set_palette(palette_id)
-
-            # Set colors if provided
-            color1 = effect_settings.get("color1")
-            if color1:
-                # Convert hex to RGB
-                r1 = int(color1[1:3], 16)
-                g1 = int(color1[3:5], 16)
-                b1 = int(color1[5:7], 16)
-
-                color2 = effect_settings.get("color2", "#000000")
-                r2 = int(color2[1:3], 16)
-                g2 = int(color2[3:5], 16)
-                b2 = int(color2[5:7], 16)
-
-                color3 = effect_settings.get("color3", "#0000ff")
-                r3 = int(color3[1:3], 16)
-                g3 = int(color3[3:5], 16)
-                b3 = int(color3[5:7], 16)
-
-                controller.set_colors(
-                    color1=(r1, g1, b1),
-                    color2=(r2, g2, b2),
-                    color3=(r3, g3, b3)
-                )
-        else:
-            # Default: Rainbow effect with speed 60 for smoother animation
-            controller.set_effect(8, speed=60, intensity=controller._intensity)
-            controller.set_colors(
-                color1=controller._color1,
-                color2=controller._color2,
-                color3=controller._color3
-            )
-
-        return True
-    except Exception as e:
-        logger.error(f"Error setting idle effect: {e}")
-        return False
-
-
-def effect_connected(controller: DWLEDController) -> bool:
-    """Show connected effect (green flash)"""
-    try:
-        controller.set_power(1)
-        controller.set_color(0, 255, 0)  # Green
-        controller.set_effect(1, speed=200, intensity=128)  # Blink effect
-        time.sleep(1.0)
-        return True
-    except Exception as e:
-        logger.error(f"Error setting connected effect: {e}")
-        return False
-
-
-def effect_playing(controller: DWLEDController, effect_settings: Optional[dict] = None) -> bool:
-    """Show playing effect with full settings"""
-    try:
-        if effect_settings and isinstance(effect_settings, dict):
-            # New format: full settings dict
-            controller.set_power(1)
-
-            # Set effect
-            effect_id = effect_settings.get("effect_id", 0)
-            palette_id = effect_settings.get("palette_id", 0)
-            speed = effect_settings.get("speed", 128)
-            intensity = effect_settings.get("intensity", 128)
-
-            controller.set_effect(effect_id, speed=speed, intensity=intensity)
-            controller.set_palette(palette_id)
-
-            # Set colors if provided
-            color1 = effect_settings.get("color1")
-            if color1:
-                # Convert hex to RGB
-                r1 = int(color1[1:3], 16)
-                g1 = int(color1[3:5], 16)
-                b1 = int(color1[5:7], 16)
-
-                color2 = effect_settings.get("color2", "#000000")
-                r2 = int(color2[1:3], 16)
-                g2 = int(color2[3:5], 16)
-                b2 = int(color2[5:7], 16)
-
-                color3 = effect_settings.get("color3", "#0000ff")
-                r3 = int(color3[1:3], 16)
-                g3 = int(color3[3:5], 16)
-                b3 = int(color3[5:7], 16)
-
-                controller.set_colors(
-                    color1=(r1, g1, b1),
-                    color2=(r2, g2, b2),
-                    color3=(r3, g3, b3)
-                )
-
-            return True
-
-        # Default: do nothing (keep current LED state)
-        return True
-    except Exception as e:
-        logger.error(f"Error setting playing effect: {e}")
-        return False

+ 0 - 4
modules/led/dw_leds/__init__.py

@@ -1,4 +0,0 @@
-"""
-WLED RPI - NeoPixel LED controller for Raspberry Pi
-Embedded into Dune Weaver sand table project
-"""

+ 0 - 1
modules/led/dw_leds/effects/__init__.py

@@ -1 +0,0 @@
-"""WLED RPI effects"""

+ 0 - 1131
modules/led/dw_leds/effects/basic_effects.py

@@ -1,1131 +0,0 @@
-#!/usr/bin/env python3
-"""
-WLED Basic Effects for Raspberry Pi
-Effects 0-30: Static, Blink, Rainbow, Scan, etc.
-Ported from WLED FX.cpp
-"""
-import random
-from ..segment import Segment
-from ..utils.colors import *
-
-# Effect return value is delay in milliseconds
-FRAMETIME = 24  # ~42 FPS
-
-def mode_static(seg: Segment) -> int:
-    """Solid color"""
-    seg.fill(seg.get_color(0))
-    return 350 if seg.call == 0 else FRAMETIME
-
-def mode_blink(seg: Segment) -> int:
-    """Blink between two colors"""
-    cycle_time = (255 - seg.speed) * 20
-    on_time = FRAMETIME + ((cycle_time * seg.intensity) >> 8)
-    cycle_time += FRAMETIME * 2
-
-    now = seg.now()
-    iteration = now // cycle_time
-    rem = now % cycle_time
-
-    on = (iteration != seg.step) or (rem <= on_time)
-    seg.step = iteration
-
-    seg.fill(seg.get_color(0) if on else seg.get_color(1))
-    return FRAMETIME
-
-def mode_strobe(seg: Segment) -> int:
-    """Strobe effect"""
-    cycle_time = (255 - seg.speed) * 20 + FRAMETIME * 2
-    now = seg.now()
-    iteration = now // cycle_time
-    on = (iteration != seg.step)
-    seg.step = iteration
-
-    seg.fill(seg.get_color(0) if on else seg.get_color(1))
-    return FRAMETIME
-
-def mode_breath(seg: Segment) -> int:
-    """Breathing effect"""
-    counter = (seg.now() * ((seg.speed >> 3) + 10)) & 0xFFFF
-    counter = (counter >> 2) + (counter >> 4)
-
-    var = 0
-    if counter < 16384:
-        if counter > 8192:
-            counter = 8192 - (counter - 8192)
-        var = sin16(counter) // 103
-
-    lum = 30 + var
-    for i in range(seg.length):
-        seg.set_pixel_color(i, color_blend(seg.get_color(1),
-                                          seg.color_from_palette(i),
-                                          lum & 0xFF))
-    return FRAMETIME
-
-def mode_fade(seg: Segment) -> int:
-    """Fade between two colors"""
-    counter = seg.now() * ((seg.speed >> 3) + 10)
-    lum = triwave16(counter & 0xFFFF) >> 8
-
-    for i in range(seg.length):
-        seg.set_pixel_color(i, color_blend(seg.get_color(1),
-                                          seg.color_from_palette(i),
-                                          lum))
-    return FRAMETIME
-
-def mode_scan(seg: Segment) -> int:
-    """Scanning pixel"""
-    if seg.length <= 1:
-        return mode_static(seg)
-
-    cycle_time = 750 + (255 - seg.speed) * 150
-    perc = seg.now() % cycle_time
-    prog = (perc * 65535) // cycle_time
-    size = 1 + ((seg.intensity * seg.length) >> 9)
-    led_index = (prog * ((seg.length * 2) - size * 2)) >> 16
-
-    seg.fill(seg.get_color(1))
-
-    led_offset = led_index - (seg.length - size)
-    led_offset = abs(led_offset)
-
-    for j in range(led_offset, min(led_offset + size, seg.length)):
-        seg.set_pixel_color(j, seg.color_from_palette(j))
-
-    return FRAMETIME
-
-def mode_dual_scan(seg: Segment) -> int:
-    """Dual scanning pixels"""
-    if seg.length <= 1:
-        return mode_static(seg)
-
-    cycle_time = 750 + (255 - seg.speed) * 150
-    perc = seg.now() % cycle_time
-    prog = (perc * 65535) // cycle_time
-    size = 1 + ((seg.intensity * seg.length) >> 9)
-    led_index = (prog * ((seg.length * 2) - size * 2)) >> 16
-
-    seg.fill(seg.get_color(1))
-
-    led_offset = led_index - (seg.length - size)
-    led_offset = abs(led_offset)
-
-    # First scanner
-    for j in range(led_offset, min(led_offset + size, seg.length)):
-        seg.set_pixel_color(j, seg.color_from_palette(j))
-
-    # Second scanner (opposite direction)
-    for j in range(led_offset, min(led_offset + size, seg.length)):
-        i2 = seg.length - 1 - j
-        seg.set_pixel_color(i2, seg.color_from_palette(i2))
-
-    return FRAMETIME
-
-def mode_rainbow(seg: Segment) -> int:
-    """Solid rainbow (cycles through hues)"""
-    counter = (seg.now() * ((seg.speed >> 2) + 2)) & 0xFFFF
-    counter = counter >> 8
-
-    if seg.intensity < 128:
-        color = color_blend(color_wheel(counter), WHITE,
-                           128 - seg.intensity)
-    else:
-        color = color_wheel(counter)
-
-    seg.fill(color)
-    return FRAMETIME
-
-def mode_rainbow_cycle(seg: Segment) -> int:
-    """Rainbow distributed across strip"""
-    counter = (seg.now() * ((seg.speed >> 2) + 2)) & 0xFFFF
-    counter = counter >> 8
-
-    for i in range(seg.length):
-        # intensity controls density
-        index = (i * (16 << (seg.intensity // 29)) // seg.length) + counter
-        seg.set_pixel_color(i, color_wheel(index & 0xFF))
-
-    return FRAMETIME
-
-def mode_theater_chase(seg: Segment) -> int:
-    """Theater chase effect"""
-    width = 3 + (seg.intensity >> 4)
-    cycle_time = 50 + (255 - seg.speed)
-    iteration = seg.now() // cycle_time
-
-    for i in range(seg.length):
-        if (i % width) == seg.aux0:
-            seg.set_pixel_color(i, seg.color_from_palette(i))
-        else:
-            seg.set_pixel_color(i, seg.get_color(1))
-
-    if iteration != seg.step:
-        seg.aux0 = (seg.aux0 + 1) % width
-        seg.step = iteration
-
-    return FRAMETIME
-
-def mode_running_lights(seg: Segment) -> int:
-    """Running lights with sine wave"""
-    x_scale = seg.intensity >> 2
-    counter = (seg.now() * seg.speed) >> 9
-
-    for i in range(seg.length):
-        a = i * x_scale - counter
-        s = sin8(a & 0xFF)
-        color = color_blend(seg.get_color(1),
-                           seg.color_from_palette(i), s)
-        seg.set_pixel_color(i, color)
-
-    return FRAMETIME
-
-def mode_color_wipe(seg: Segment) -> int:
-    """Color wipe effect"""
-    if seg.length <= 1:
-        return mode_static(seg)
-
-    cycle_time = 750 + (255 - seg.speed) * 150
-    perc = seg.now() % cycle_time
-    prog = (perc * 65535) // cycle_time
-    back = prog > 32767
-
-    if back:
-        prog -= 32767
-        if seg.step == 0:
-            seg.step = 1
-    else:
-        if seg.step == 2:
-            seg.step = 3
-
-    led_index = (prog * seg.length) >> 15
-    rem = (prog * seg.length) * 2
-    rem //= (seg.intensity + 1)
-    rem = min(255, rem)
-
-    col0 = seg.get_color(0)
-    col1 = seg.get_color(1)
-
-    for i in range(seg.length):
-        if i < led_index:
-            seg.set_pixel_color(i, col1 if back else col0)
-        else:
-            seg.set_pixel_color(i, col0 if back else col1)
-            if i == led_index:
-                blended = color_blend(col1 if back else col0,
-                                     col0 if back else col1,
-                                     rem)
-                seg.set_pixel_color(i, blended)
-
-    return FRAMETIME
-
-def mode_random_color(seg: Segment) -> int:
-    """Random solid colors with fade"""
-    cycle_time = 200 + (255 - seg.speed) * 50
-    iteration = seg.now() // cycle_time
-    rem = seg.now() % cycle_time
-    fade_dur = (cycle_time * seg.intensity) >> 8
-
-    fade = 255
-    if fade_dur:
-        fade = (rem * 255) // fade_dur
-        fade = min(255, fade)
-
-    if seg.call == 0:
-        seg.aux0 = random.randint(0, 255)
-        seg.step = 2
-
-    if iteration != seg.step:
-        seg.aux1 = seg.aux0
-        seg.aux0 = random.randint(0, 255)
-        seg.step = iteration
-
-    color = color_blend(color_wheel(seg.aux1),
-                       color_wheel(seg.aux0), fade)
-    seg.fill(color)
-    return FRAMETIME
-
-def mode_dynamic(seg: Segment) -> int:
-    """Dynamic random colors per pixel"""
-    if seg.call == 0:
-        seg.data = [random.randint(0, 255) for _ in range(seg.length)]
-
-    cycle_time = 50 + (255 - seg.speed) * 15
-    iteration = seg.now() // cycle_time
-
-    if iteration != seg.step and seg.speed != 0:
-        for i in range(seg.length):
-            if random.randint(0, 255) <= seg.intensity:
-                seg.data[i] = random.randint(0, 255)
-        seg.step = iteration
-
-    for i in range(seg.length):
-        seg.set_pixel_color(i, color_wheel(seg.data[i]))
-
-    return FRAMETIME
-
-def mode_twinkle(seg: Segment) -> int:
-    """Twinkle effect"""
-    seg.fade_out(224)
-
-    cycle_time = 20 + (255 - seg.speed) * 5
-    iteration = seg.now() // cycle_time
-
-    if iteration != seg.step:
-        max_on = max(1, (seg.intensity * seg.length) // 255)
-        if seg.aux0 >= max_on:
-            seg.aux0 = 0
-            seg.aux1 = random.randint(0, 0xFFFF)
-        seg.aux0 += 1
-        seg.step = iteration
-
-    prng = seg.aux1
-    for _ in range(seg.aux0):
-        prng = (prng * 2053 + 13849) & 0xFFFF
-        j = (prng * seg.length) >> 16
-        if j < seg.length:
-            seg.set_pixel_color(j, seg.color_from_palette(j))
-
-    return FRAMETIME
-
-def mode_sparkle(seg: Segment) -> int:
-    """Single sparkle effect"""
-    for i in range(seg.length):
-        seg.set_pixel_color(i, seg.color_from_palette(i))
-
-    cycle_time = 10 + (255 - seg.speed) * 2
-    iteration = seg.now() // cycle_time
-
-    if iteration != seg.step:
-        seg.aux0 = random.randint(0, seg.length - 1)
-        seg.step = iteration
-
-    seg.set_pixel_color(seg.aux0, seg.get_color(0))
-    return FRAMETIME
-
-def mode_fire(seg: Segment) -> int:
-    """Fire/flame effect"""
-    if seg.call == 0:
-        seg.data = [0] * seg.length
-
-    # Cooling parameter (higher = cooler flames)
-    cooling = ((100 - (seg.intensity >> 1)) * 10) // seg.length + 2
-
-    # Heat decay for all pixels
-    for i in range(seg.length):
-        cool_down = random.randint(0, cooling)
-        seg.data[i] = max(0, seg.data[i] - cool_down)
-
-    # Heat drift upward
-    for i in range(seg.length - 1, 2, -1):
-        seg.data[i] = (seg.data[i - 1] + seg.data[i - 2] + seg.data[i - 2]) // 3
-
-    # Randomly ignite new sparks near bottom
-    if random.randint(0, 255) < seg.intensity:
-        spark_pos = random.randint(0, min(7, seg.length - 1))
-        seg.data[spark_pos] = min(255, seg.data[spark_pos] + random.randint(160, 255))
-
-    # Convert heat to colors
-    for i in range(seg.length):
-        heat = seg.data[i]
-
-        # Black -> Red -> Yellow -> White
-        if heat < 85:
-            color = (heat * 3, 0, 0)
-        elif heat < 170:
-            h = heat - 85
-            color = (255, h * 3, 0)
-        else:
-            h = heat - 170
-            color = (255, 255, h * 3)
-
-        r, g, b = color
-        seg.set_pixel_color(i, (r << 16) | (g << 8) | b)
-
-    return FRAMETIME
-
-def mode_comet(seg: Segment) -> int:
-    """Comet/shooting star effect"""
-    if seg.call == 0:
-        seg.aux0 = 0
-        seg.aux1 = 0
-
-    seg.fade_out(128)
-
-    size = 1 + ((seg.intensity * seg.length) >> 9)
-    cycle_time = 10 + (255 - seg.speed)
-    iteration = seg.now() // cycle_time
-
-    if iteration != seg.step:
-        seg.aux0 = (seg.aux0 + 1) % seg.length
-        seg.step = iteration
-
-    # Draw comet
-    for i in range(size):
-        pos = (seg.aux0 - i) % seg.length
-        brightness = 255 - (i * 255 // max(1, size))
-        color = color_blend(0, seg.color_from_palette(pos), brightness)
-        seg.set_pixel_color(pos, color)
-
-    return FRAMETIME
-
-def mode_chase(seg: Segment) -> int:
-    """Chase effect with colored segments"""
-    if seg.call == 0:
-        seg.aux0 = 0
-
-    size = max(1, seg.length // 4)
-    cycle_time = 10 + (255 - seg.speed)
-    iteration = seg.now() // cycle_time
-
-    if iteration != seg.step:
-        seg.aux0 = (seg.aux0 + 1) % seg.length
-        seg.step = iteration
-
-    seg.fill(seg.get_color(1))
-
-    for i in range(size):
-        pos = (seg.aux0 + i) % seg.length
-        seg.set_pixel_color(pos, seg.color_from_palette(pos))
-
-    return FRAMETIME
-
-def mode_police(seg: Segment) -> int:
-    """Police lights (red/blue alternating)"""
-    cycle_time = 25 + (255 - seg.speed)
-    on_time = cycle_time // 2
-
-    now = seg.now()
-    iteration = now // cycle_time
-    rem = now % cycle_time
-    on = rem < on_time
-
-    half = seg.length // 2
-
-    # Red on left, blue on right
-    red = (255, 0, 0)
-    blue = (0, 0, 255)
-    off_color = (0, 0, 0)
-
-    for i in range(half):
-        if (iteration % 2 == 0 and on) or (iteration % 2 == 1 and not on):
-            seg.set_pixel_color(i, (red[0] << 16) | (red[1] << 8) | red[2])
-        else:
-            seg.set_pixel_color(i, (off_color[0] << 16) | (off_color[1] << 8) | off_color[2])
-
-    for i in range(half, seg.length):
-        if (iteration % 2 == 1 and on) or (iteration % 2 == 0 and not on):
-            seg.set_pixel_color(i, (blue[0] << 16) | (blue[1] << 8) | blue[2])
-        else:
-            seg.set_pixel_color(i, (off_color[0] << 16) | (off_color[1] << 8) | off_color[2])
-
-    return FRAMETIME
-
-def mode_lightning(seg: Segment) -> int:
-    """Lightning flash effect"""
-    if seg.call == 0:
-        seg.aux0 = 0
-        seg.aux1 = 0
-
-    cycle_time = 50 + (255 - seg.speed) * 10
-    iteration = seg.now() // cycle_time
-
-    if iteration != seg.step:
-        # Random chance of lightning
-        if random.randint(0, 255) < seg.intensity:
-            seg.aux0 = random.randint(3, 8)  # Number of flashes
-            seg.aux1 = seg.now()
-        seg.step = iteration
-
-    # Flash sequence
-    if seg.aux0 > 0:
-        flash_duration = 50
-        time_since = seg.now() - seg.aux1
-
-        if time_since < flash_duration:
-            # Flash on
-            brightness = 255 - (time_since * 255 // flash_duration)
-            for i in range(seg.length):
-                color = color_blend(0, WHITE, brightness)
-                seg.set_pixel_color(i, color)
-        else:
-            # Flash off, wait for next
-            if time_since > flash_duration + random.randint(10, 100):
-                seg.aux0 -= 1
-                seg.aux1 = seg.now()
-            else:
-                seg.fill(seg.get_color(1))
-    else:
-        seg.fill(seg.get_color(1))
-
-    return FRAMETIME
-
-def mode_fireworks(seg: Segment) -> int:
-    """Fireworks effect"""
-    seg.fade_out(64)
-
-    cycle_time = 20 + (255 - seg.speed)
-    iteration = seg.now() // cycle_time
-
-    if iteration != seg.step:
-        # Launch new firework
-        if random.randint(0, 255) < seg.intensity:
-            pos = random.randint(0, seg.length - 1)
-            color = color_wheel(random.randint(0, 255))
-
-            # Bright center
-            seg.set_pixel_color(pos, color)
-
-            # Dimmer neighbors
-            if pos > 0:
-                seg.set_pixel_color(pos - 1, color_blend(0, color, 128))
-            if pos < seg.length - 1:
-                seg.set_pixel_color(pos + 1, color_blend(0, color, 128))
-
-        seg.step = iteration
-
-    return FRAMETIME
-
-def mode_ripple(seg: Segment) -> int:
-    """Ripple effect"""
-    if seg.call == 0:
-        seg.data = [0] * seg.length
-        seg.aux0 = seg.length // 2
-
-    seg.fade_out(250)
-
-    cycle_time = 50 + (255 - seg.speed) * 2
-    iteration = seg.now() // cycle_time
-
-    if iteration != seg.step:
-        # New ripple
-        if random.randint(0, 255) < seg.intensity:
-            seg.aux0 = random.randint(0, seg.length - 1)
-            seg.data[seg.aux0] = 255
-
-        # Propagate ripple
-        new_data = seg.data.copy()
-        for i in range(1, seg.length - 1):
-            new_data[i] = (seg.data[i - 1] + seg.data[i + 1]) // 2
-        seg.data = new_data
-
-        seg.step = iteration
-
-    for i in range(seg.length):
-        if seg.data[i] > 0:
-            color = color_blend(seg.get_color(1),
-                              seg.color_from_palette(i),
-                              seg.data[i])
-            seg.set_pixel_color(i, color)
-
-    return FRAMETIME
-
-def mode_flow(seg: Segment) -> int:
-    """Smooth flowing color movement"""
-    counter = seg.now() * ((seg.speed >> 3) + 1)
-
-    for i in range(seg.length):
-        pos = ((i * 256 // seg.length) + counter) & 0xFFFF
-        color = color_wheel((pos >> 8) & 0xFF)
-
-        # Apply intensity as brightness modulation
-        brightness = 128 + ((sin8((pos >> 7) & 0xFF) - 128) * seg.intensity // 255)
-        color = color_blend(0, color, brightness)
-        seg.set_pixel_color(i, color)
-
-    return FRAMETIME
-
-def mode_colorloop(seg: Segment) -> int:
-    """Smooth color loop across entire strip"""
-    counter = (seg.now() * ((seg.speed >> 3) + 1)) & 0xFFFF
-
-    for i in range(seg.length):
-        # Create gradient based on position and time
-        hue = ((i * 256 // max(1, seg.length)) + (counter >> 7)) & 0xFF
-        color = color_wheel(hue)
-
-        # Intensity controls saturation
-        if seg.intensity < 255:
-            color = color_blend(color, WHITE, 255 - seg.intensity)
-
-        seg.set_pixel_color(i, color)
-
-    return FRAMETIME
-
-def mode_palette_flow(seg: Segment) -> int:
-    """Flowing palette colors"""
-    counter = seg.now() * ((seg.speed >> 3) + 1)
-
-    for i in range(seg.length):
-        # Get color from palette based on position and time
-        palette_pos = ((i * 255 // max(1, seg.length)) + (counter >> 7)) & 0xFF
-        color = seg.color_from_palette(palette_pos)
-
-        # Intensity controls brightness modulation
-        if seg.intensity < 255:
-            brightness = 128 + ((sin8(palette_pos) - 128) * seg.intensity // 255)
-            color = color_blend(0, color, brightness)
-
-        seg.set_pixel_color(i, color)
-
-    return FRAMETIME
-
-def mode_gradient(seg: Segment) -> int:
-    """Smooth gradient between colors"""
-    for i in range(seg.length):
-        # Create gradient from color 0 to color 2
-        blend_amount = (i * 255) // max(1, seg.length - 1)
-        color = color_blend(seg.get_color(0), seg.get_color(2), blend_amount)
-
-        # Intensity controls a pulsing brightness
-        if seg.intensity > 0:
-            counter = (seg.now() * ((seg.speed >> 3) + 1)) & 0xFFFF
-            pulse = sin8((counter >> 8) & 0xFF)
-            brightness = 128 + ((pulse - 128) * seg.intensity // 255)
-            color = color_blend(0, color, brightness)
-
-        seg.set_pixel_color(i, color)
-
-    return FRAMETIME
-
-def mode_multi_strobe(seg: Segment) -> int:
-    """Multi-color strobe effect"""
-    cycle_time = 50 + (255 - seg.speed)
-    flash_duration = max(5, cycle_time // 4)
-
-    now = seg.now()
-    iteration = now // cycle_time
-    rem = now % cycle_time
-
-    if rem < flash_duration:
-        # Strobe on with color from palette
-        color_index = (iteration * 85) & 0xFF
-        color = color_wheel(color_index)
-        seg.fill(color)
-    else:
-        # Strobe off
-        seg.fill(seg.get_color(1))
-
-    return FRAMETIME
-
-def mode_waves(seg: Segment) -> int:
-    """Sine wave effect"""
-    counter = seg.now() * ((seg.speed >> 3) + 1)
-
-    for i in range(seg.length):
-        # Create wave pattern
-        wave_pos = (i * 255 // max(1, seg.length)) + (counter >> 7)
-        brightness = sin8(wave_pos & 0xFF)
-
-        # Intensity controls wave amplitude
-        brightness = 128 + ((brightness - 128) * seg.intensity // 255)
-
-        color = color_blend(seg.get_color(1),
-                          seg.color_from_palette(i),
-                          brightness)
-        seg.set_pixel_color(i, color)
-
-    return FRAMETIME
-
-def mode_bpm(seg: Segment) -> int:
-    """BPM (beats per minute) pulse effect"""
-    # Calculate BPM based on speed (60-180 BPM)
-    bpm = 60 + ((seg.speed * 120) >> 8)
-    ms_per_beat = 60000 // bpm
-
-    beat_phase = (seg.now() % ms_per_beat) * 255 // ms_per_beat
-    brightness = sin8(beat_phase)
-
-    for i in range(seg.length):
-        # Create traveling beat
-        offset = (i * 255 // max(1, seg.length))
-        local_brightness = sin8((beat_phase + offset) & 0xFF)
-
-        # Intensity controls brightness range
-        local_brightness = 128 + ((local_brightness - 128) * seg.intensity // 255)
-
-        color = color_blend(seg.get_color(1),
-                          seg.color_from_palette(i),
-                          local_brightness)
-        seg.set_pixel_color(i, color)
-
-    return FRAMETIME
-
-def mode_juggle(seg: Segment) -> int:
-    """Juggling colored dots"""
-    if seg.call == 0:
-        seg.data = [0] * 8  # Track 8 dot positions
-
-    seg.fade_out(224)
-
-    cycle_time = 10 + (255 - seg.speed) // 2
-    iteration = seg.now() // cycle_time
-
-    if iteration != seg.step:
-        # Update dot positions using different sine waves
-        for dot in range(min(8, 1 + seg.intensity // 32)):
-            phase = (seg.now() * (dot + 1)) & 0xFFFF
-            pos = (sin16(phase) + 32768) * seg.length // 65536
-            pos = max(0, min(seg.length - 1, pos))
-
-            hue = (dot * 32) & 0xFF
-            color = color_wheel(hue)
-            seg.set_pixel_color(pos, color)
-
-        seg.step = iteration
-
-    return FRAMETIME
-
-def mode_meteor(seg: Segment) -> int:
-    """Meteor shower effect with trails"""
-    if seg.call == 0:
-        seg.aux0 = 0
-        seg.aux1 = 0
-
-    # Fade all pixels
-    seg.fade_out(200)
-
-    size = 1 + ((seg.intensity * seg.length) >> 8)
-    cycle_time = 10 + (255 - seg.speed)
-    iteration = seg.now() // cycle_time
-
-    if iteration != seg.step:
-        seg.aux0 = (seg.aux0 + 1) % (seg.length + size)
-        seg.step = iteration
-
-    # Draw meteor head and tail
-    if seg.aux0 < seg.length:
-        for i in range(size):
-            pos = seg.aux0 - i
-            if 0 <= pos < seg.length:
-                brightness = 255 - (i * 200 // max(1, size))
-                color = color_blend(0, seg.color_from_palette(pos), brightness)
-                # Add to existing color for brighter effect
-                existing = seg.get_pixel_color(pos)
-                seg.set_pixel_color(pos, color_add(existing, color))
-
-    return FRAMETIME
-
-def mode_pride(seg: Segment) -> int:
-    """Pride flag colors moving effect"""
-    counter = seg.now() * ((seg.speed >> 3) + 1)
-
-    # Pride flag colors (6 stripes)
-    pride_colors = [
-        (0xE4, 0x00, 0x3A),  # Red
-        (0xFF, 0x8C, 0x00),  # Orange
-        (0xFF, 0xED, 0x00),  # Yellow
-        (0x00, 0x81, 0x1F),  # Green
-        (0x00, 0x4C, 0xFF),  # Blue
-        (0x76, 0x01, 0x89),  # Purple
-    ]
-
-    for i in range(seg.length):
-        # Determine which stripe this pixel belongs to
-        stripe_size = max(1, seg.length // 6)
-        offset = (counter >> 7) & 0xFF
-        stripe_idx = ((i + offset) // stripe_size) % 6
-
-        r, g, b = pride_colors[stripe_idx]
-        color = (r << 16) | (g << 8) | b
-
-        # Intensity controls blending with background
-        if seg.intensity < 255:
-            color = color_blend(seg.get_color(1), color, seg.intensity)
-
-        seg.set_pixel_color(i, color)
-
-    return FRAMETIME
-
-def mode_pacifica(seg: Segment) -> int:
-    """Ocean/water simulation"""
-    if seg.call == 0:
-        seg.data = [0] * seg.length
-
-    counter = seg.now() * ((seg.speed >> 4) + 1)
-
-    for i in range(seg.length):
-        # Create multiple layered waves
-        wave1 = sin8(((i * 5) + (counter >> 5)) & 0xFF)
-        wave2 = sin8(((i * 3) + (counter >> 3)) & 0xFF)
-        wave3 = sin8(((i * 7) + (counter >> 6)) & 0xFF)
-
-        # Combine waves
-        brightness = (wave1 + wave2 + wave3) // 3
-
-        # Blue-green ocean colors
-        if brightness < 64:
-            # Deep blue
-            r, g, b = 0, 0, 60 + brightness
-        elif brightness < 128:
-            # Blue-cyan
-            val = brightness - 64
-            r, g, b = 0, val * 2, 100 + val
-        else:
-            # Cyan-white (foam)
-            val = brightness - 128
-            r, g, b = val, 128 + val, 180 + (val // 2)
-
-        # Apply intensity
-        brightness = 128 + ((brightness - 128) * seg.intensity // 255)
-        color = (r << 16) | (g << 8) | b
-        color = color_blend(0, color, brightness)
-
-        seg.set_pixel_color(i, color)
-
-    return FRAMETIME
-
-def mode_plasma(seg: Segment) -> int:
-    """Plasma effect"""
-    counter = seg.now() * ((seg.speed >> 3) + 1)
-
-    for i in range(seg.length):
-        # Create plasma using multiple sine waves
-        phase1 = sin8(((i * 16) + (counter >> 5)) & 0xFF)
-        phase2 = sin8(((i * 8) + (counter >> 6)) & 0xFF)
-        phase3 = sin8((counter >> 4) & 0xFF)
-
-        # Combine phases
-        hue = (phase1 + phase2 + phase3) & 0xFF
-        color = color_wheel(hue)
-
-        # Intensity controls saturation
-        if seg.intensity < 255:
-            color = color_blend(color, WHITE, 255 - seg.intensity)
-
-        seg.set_pixel_color(i, color)
-
-    return FRAMETIME
-
-def mode_dissolve(seg: Segment) -> int:
-    """Random pixel dissolve/fade"""
-    if seg.call == 0:
-        seg.data = [0] * seg.length
-        for i in range(seg.length):
-            seg.data[i] = random.randint(0, 255)
-
-    cycle_time = 20 + (255 - seg.speed)
-    iteration = seg.now() // cycle_time
-
-    if iteration != seg.step:
-        # Randomly update some pixels
-        for i in range(seg.length):
-            if random.randint(0, 255) < seg.intensity:
-                seg.data[i] = random.randint(0, 255)
-
-        seg.step = iteration
-
-    for i in range(seg.length):
-        brightness = seg.data[i]
-        color = color_blend(seg.get_color(1),
-                          seg.color_from_palette(i),
-                          brightness)
-        seg.set_pixel_color(i, color)
-
-    return FRAMETIME
-
-def mode_glitter(seg: Segment) -> int:
-    """Sparkle glitter overlay"""
-    # Fill with base color
-    for i in range(seg.length):
-        seg.set_pixel_color(i, seg.color_from_palette(i))
-
-    # Add random sparkles based on intensity
-    sparkle_chance = seg.intensity
-    num_sparkles = max(1, (seg.length * sparkle_chance) // 255)
-
-    for _ in range(num_sparkles):
-        if random.randint(0, 255) < seg.speed:
-            pos = random.randint(0, seg.length - 1)
-            seg.set_pixel_color(pos, WHITE)
-
-    return FRAMETIME
-
-def mode_confetti(seg: Segment) -> int:
-    """Random colored pixels"""
-    seg.fade_out(224)
-
-    cycle_time = 10 + (255 - seg.speed)
-    iteration = seg.now() // cycle_time
-
-    if iteration != seg.step:
-        # Add random confetti
-        density = 1 + (seg.intensity >> 5)
-        for _ in range(density):
-            pos = random.randint(0, seg.length - 1)
-            hue = random.randint(0, 255)
-            color = color_wheel(hue)
-            seg.set_pixel_color(pos, color)
-
-        seg.step = iteration
-
-    return FRAMETIME
-
-def mode_sinelon(seg: Segment) -> int:
-    """Sine wave colored dot"""
-    seg.fade_out(224)
-
-    counter = seg.now() * ((seg.speed >> 2) + 1)
-    phase = (counter >> 8) & 0xFFFF
-
-    # Calculate position using sine
-    pos = (sin16(phase) + 32768) * seg.length // 65536
-    pos = max(0, min(seg.length - 1, pos))
-
-    # Color rotates
-    hue = (counter >> 7) & 0xFF
-    color = color_wheel(hue)
-
-    # Intensity controls trail length (via fade)
-    if seg.intensity < 255:
-        color = color_blend(0, color, seg.intensity)
-
-    seg.set_pixel_color(pos, color)
-
-    return FRAMETIME
-
-def mode_candle(seg: Segment) -> int:
-    """Flickering candle simulation"""
-    if seg.call == 0:
-        seg.data = [128] * seg.length
-
-    # Random flicker for each pixel
-    for i in range(seg.length):
-        # Small random changes
-        change = random.randint(-20, 20)
-        seg.data[i] = max(30, min(255, seg.data[i] + change))
-
-        # Speed affects flicker rate
-        if seg.speed > 128 and random.randint(0, 255) < (seg.speed - 128):
-            seg.data[i] = random.randint(50, 200)
-
-        # Warm orange/yellow color
-        brightness = seg.data[i]
-        r = brightness
-        g = (brightness * 2) // 3
-        b = (brightness * seg.intensity) // 512  # Intensity controls blue
-
-        color = (r << 16) | (g << 8) | b
-        seg.set_pixel_color(i, color)
-
-    return FRAMETIME
-
-def mode_aurora(seg: Segment) -> int:
-    """Northern lights effect"""
-    if seg.call == 0:
-        seg.data = [0] * seg.length
-
-    counter = seg.now() * ((seg.speed >> 4) + 1)
-
-    for i in range(seg.length):
-        # Multiple slow-moving waves
-        wave1 = sin8(((i * 3) + (counter >> 6)) & 0xFF)
-        wave2 = sin8(((i * 5) + (counter >> 7) + 85) & 0xFF)
-        wave3 = sin8(((i * 2) + (counter >> 5) + 170) & 0xFF)
-
-        # Aurora colors: green, blue, purple
-        brightness = (wave1 + wave2 + wave3) // 3
-
-        if brightness < 85:
-            # Green
-            r, g, b = 0, brightness * 2, brightness // 2
-        elif brightness < 170:
-            # Blue-green
-            val = brightness - 85
-            r, g, b = 0, 100 + val, 100 + val
-        else:
-            # Purple-pink
-            val = brightness - 170
-            r, g, b = val * 2, val, 150 + val
-
-        # Apply intensity for brightness variation
-        brightness_mod = 128 + ((brightness - 128) * seg.intensity // 255)
-        color = (r << 16) | (g << 8) | b
-        color = color_blend(0, color, brightness_mod)
-
-        seg.set_pixel_color(i, color)
-
-    return FRAMETIME
-
-def mode_rain(seg: Segment) -> int:
-    """Rain drops falling"""
-    if seg.call == 0:
-        seg.data = [0] * seg.length
-        seg.aux0 = 0
-
-    seg.fade_out(235)
-
-    cycle_time = 15 + (255 - seg.speed)
-    iteration = seg.now() // cycle_time
-
-    if iteration != seg.step:
-        # New rain drops
-        if random.randint(0, 255) < seg.intensity:
-            pos = random.randint(0, min(5, seg.length - 1))  # Start near beginning
-            seg.data[pos] = 255
-
-        # Move drops down
-        new_data = [0] * seg.length
-        for i in range(seg.length - 1):
-            if seg.data[i] > 0:
-                # Drop moves forward
-                if i + 1 < seg.length:
-                    new_data[i + 1] = seg.data[i] - 10
-        seg.data = new_data
-
-        seg.step = iteration
-
-    # Draw rain drops (blue)
-    for i in range(seg.length):
-        if seg.data[i] > 0:
-            brightness = seg.data[i]
-            color = color_blend(0, seg.color_from_palette(i), brightness)
-            seg.set_pixel_color(i, color)
-
-    return FRAMETIME
-
-def mode_halloween(seg: Segment) -> int:
-    """Halloween orange and purple"""
-    counter = seg.now() * ((seg.speed >> 3) + 1)
-
-    # Alternating orange and purple
-    orange = (0xFF << 16) | (0x44 << 8) | 0x00
-    purple = (0x88 << 16) | (0x00 << 8) | 0xFF
-
-    for i in range(seg.length):
-        # Create moving pattern
-        phase = ((i * 255 // max(1, seg.length)) + (counter >> 7)) & 0xFF
-        wave = sin8(phase)
-
-        # Blend between orange and purple
-        if wave < 128:
-            color = orange
-        else:
-            color = purple
-
-        # Intensity controls blending smoothness
-        if seg.intensity > 0:
-            blend_amt = (wave * seg.intensity) // 255
-            color = color_blend(orange, purple, blend_amt)
-
-        seg.set_pixel_color(i, color)
-
-    return FRAMETIME
-
-def mode_noise(seg: Segment) -> int:
-    """Perlin-like noise pattern"""
-    if seg.call == 0:
-        seg.data = [random.randint(0, 255) for _ in range(seg.length)]
-
-    cycle_time = 20 + (255 - seg.speed) // 2
-    iteration = seg.now() // cycle_time
-
-    if iteration != seg.step:
-        # Smooth noise by averaging neighbors
-        new_data = seg.data.copy()
-        for i in range(1, seg.length - 1):
-            avg = (seg.data[i - 1] + seg.data[i] * 2 + seg.data[i + 1]) // 4
-            variation = random.randint(-20, 20)
-            new_data[i] = max(0, min(255, avg + variation))
-
-        # Occasionally inject new random values
-        if random.randint(0, 255) < seg.intensity:
-            pos = random.randint(0, seg.length - 1)
-            new_data[pos] = random.randint(0, 255)
-
-        seg.data = new_data
-        seg.step = iteration
-
-    # Map noise to colors
-    for i in range(seg.length):
-        hue = seg.data[i]
-        color = color_wheel(hue)
-        seg.set_pixel_color(i, color)
-
-    return FRAMETIME
-
-def mode_funky_plank(seg: Segment) -> int:
-    """Multiple colored bars moving"""
-    if seg.call == 0:
-        seg.aux0 = 0
-
-    cycle_time = 10 + (255 - seg.speed)
-    iteration = seg.now() // cycle_time
-
-    if iteration != seg.step:
-        seg.aux0 = (seg.aux0 + 1) % seg.length
-        seg.step = iteration
-
-    num_planks = max(2, 1 + (seg.intensity >> 5))
-    plank_size = max(1, seg.length // num_planks)
-
-    for i in range(seg.length):
-        plank_idx = ((i + seg.aux0) // plank_size) % num_planks
-        hue = (plank_idx * 256 // num_planks) & 0xFF
-        color = color_wheel(hue)
-        seg.set_pixel_color(i, color)
-
-    return FRAMETIME
-
-# Effect registry
-EFFECTS = {
-    0: ("Static", mode_static),
-    1: ("Blink", mode_blink),
-    2: ("Breathe", mode_breath),
-    3: ("Wipe", mode_color_wipe),
-    4: ("Fade", mode_fade),
-    5: ("Scan", mode_scan),
-    6: ("Dual Scan", mode_dual_scan),
-    7: ("Rainbow Cycle", mode_rainbow),
-    8: ("Rainbow", mode_rainbow_cycle),
-    9: ("Theater Chase", mode_theater_chase),
-    10: ("Running Lights", mode_running_lights),
-    11: ("Random Color", mode_random_color),
-    12: ("Dynamic", mode_dynamic),
-    13: ("Twinkle", mode_twinkle),
-    14: ("Sparkle", mode_sparkle),
-    15: ("Strobe", mode_strobe),
-    16: ("Fire", mode_fire),
-    17: ("Comet", mode_comet),
-    18: ("Chase", mode_chase),
-    19: ("Police", mode_police),
-    20: ("Lightning", mode_lightning),
-    21: ("Fireworks", mode_fireworks),
-    22: ("Ripple", mode_ripple),
-    23: ("Flow", mode_flow),
-    24: ("Colorloop", mode_colorloop),
-    25: ("Palette Flow", mode_palette_flow),
-    26: ("Gradient", mode_gradient),
-    27: ("Multi Strobe", mode_multi_strobe),
-    28: ("Waves", mode_waves),
-    29: ("BPM", mode_bpm),
-    30: ("Juggle", mode_juggle),
-    31: ("Meteor", mode_meteor),
-    32: ("Pride", mode_pride),
-    33: ("Pacifica", mode_pacifica),
-    34: ("Plasma", mode_plasma),
-    35: ("Dissolve", mode_dissolve),
-    36: ("Glitter", mode_glitter),
-    37: ("Confetti", mode_confetti),
-    38: ("Sinelon", mode_sinelon),
-    39: ("Candle", mode_candle),
-    40: ("Aurora", mode_aurora),
-    41: ("Rain", mode_rain),
-    42: ("Halloween", mode_halloween),
-    43: ("Noise", mode_noise),
-    44: ("Funky Plank", mode_funky_plank),
-}
-
-def get_effect(effect_id: int):
-    """Get effect function by ID"""
-    if effect_id in EFFECTS:
-        return EFFECTS[effect_id][1]
-    return mode_static
-
-def get_effect_name(effect_id: int) -> str:
-    """Get effect name by ID"""
-    if effect_id in EFFECTS:
-        return EFFECTS[effect_id][0]
-    return "Unknown"
-
-def get_all_effects():
-    """Get list of all effects"""
-    return [(k, v[0]) for k, v in sorted(EFFECTS.items())]

+ 0 - 154
modules/led/dw_leds/segment.py

@@ -1,154 +0,0 @@
-#!/usr/bin/env python3
-"""
-WLED Segment class for Raspberry Pi
-Manages LED strip segments and their effects
-"""
-import time
-from typing import List, Callable, Optional
-from .utils.colors import *
-from .utils.palettes import color_from_palette, get_palette
-
-class Segment:
-    """LED segment with effect support"""
-
-    def __init__(self, pixels, start: int, stop: int, is_rgbw: bool = False):
-        """
-        Initialize a segment
-        pixels: neopixel.NeoPixel object
-        start: starting LED index
-        stop: ending LED index (exclusive)
-        is_rgbw: True for RGBW strips (SK6812), writes 4-tuples instead of 3
-        """
-        self.pixels = pixels
-        self.start = start
-        self.stop = stop
-        self.is_rgbw = is_rgbw
-        self.length = stop - start
-
-        # Colors (up to 3 colors like WLED)
-        self.colors = [0x00FF0000, 0x000000FF, 0x0000FF00]  # Red, Blue, Green defaults
-
-        # Effect parameters
-        self.speed = 128        # 0-255
-        self.intensity = 128    # 0-255
-        self.palette_id = 0     # Palette ID
-        self.custom1 = 0        # Custom parameter 1
-        self.custom2 = 0        # Custom parameter 2
-        self.custom3 = 0        # Custom parameter 3
-
-        # Runtime state (like SEGENV in WLED)
-        self.call = 0           # Number of times effect has been called
-        self.step = 0           # Effect step counter
-        self.aux0 = 0           # Auxiliary variable 0
-        self.aux1 = 0           # Auxiliary variable 1
-        self.next_time = 0      # Next time to run effect (ms)
-        self.data = []          # Effect data storage
-
-        # Timing
-        self._start_time = time.time() * 1000  # Convert to milliseconds
-
-    def get_pixel_color(self, i: int) -> int:
-        """Get color of pixel at index i (segment-relative)"""
-        if i < 0 or i >= self.length:
-            return 0
-        actual_idx = self.start + i
-        color = self.pixels[actual_idx]
-        # Convert from neopixel (R,G,B) or (G,R,B) to 32-bit
-        if isinstance(color, tuple):
-            if len(color) == 3:
-                return (color[0] << 16) | (color[1] << 8) | color[2]
-            elif len(color) == 4:
-                return (color[3] << 24) | (color[0] << 16) | (color[1] << 8) | color[2]
-        return 0
-
-    def set_pixel_color(self, i: int, color: int):
-        """Set color of pixel at index i (segment-relative)"""
-        if i < 0 or i >= self.length:
-            return
-        actual_idx = self.start + i
-        r = get_r(color)
-        g = get_g(color)
-        b = get_b(color)
-        if self.is_rgbw:
-            w = get_w(color)
-            if w == 0 and (r > 0 and g > 0 and b > 0):
-                # Auto white balance: extract white component from RGB
-                # This uses the dedicated white LED for better color accuracy
-                w = min(r, g, b)
-                r -= w
-                g -= w
-                b -= w
-            self.pixels[actual_idx] = (r, g, b, w)
-        else:
-            self.pixels[actual_idx] = (r, g, b)
-
-    def fill(self, color: int):
-        """Fill entire segment with color"""
-        for i in range(self.length):
-            self.set_pixel_color(i, color)
-
-    def fade_out(self, amount: int):
-        """Fade all pixels in segment toward black"""
-        for i in range(self.length):
-            current = self.get_pixel_color(i)
-            faded = color_fade(current, amount)
-            self.set_pixel_color(i, faded)
-
-    def blur(self, amount: int):
-        """Blur/blend pixels with neighbors"""
-        if amount == 0 or self.length < 3:
-            return
-
-        # Simple blur: blend each pixel with neighbors
-        temp = [self.get_pixel_color(i) for i in range(self.length)]
-
-        for i in range(self.length):
-            if i == 0:
-                # First pixel: blend with next
-                blended = color_blend(temp[i], temp[i + 1], amount)
-            elif i == self.length - 1:
-                # Last pixel: blend with previous
-                blended = color_blend(temp[i], temp[i - 1], amount)
-            else:
-                # Middle pixels: blend with both neighbors
-                left = color_blend(temp[i], temp[i - 1], amount // 2)
-                blended = color_blend(left, temp[i + 1], amount // 2)
-
-            self.set_pixel_color(i, blended)
-
-    def now(self) -> int:
-        """Get current time in milliseconds since segment start"""
-        return int((time.time() * 1000) - self._start_time)
-
-    def color_from_palette(self, index: int, use_index: bool = True,
-                          brightness: int = 255) -> int:
-        """
-        Get color from current palette
-        index: LED index or palette position
-        use_index: if True, scale index across palette
-        brightness: 0-255
-        """
-        palette = get_palette(self.palette_id)
-
-        if use_index and self.length > 1:
-            # Map LED position to palette position
-            palette_pos = (index * 255) // (self.length - 1)
-        else:
-            palette_pos = index & 0xFF
-
-        return color_from_palette(palette, palette_pos, brightness)
-
-    def get_color(self, index: int) -> int:
-        """Get segment color by index (0-2)"""
-        if 0 <= index < len(self.colors):
-            return self.colors[index]
-        return 0
-
-    def reset(self):
-        """Reset segment state"""
-        self.call = 0
-        self.step = 0
-        self.aux0 = 0
-        self.aux1 = 0
-        self.next_time = 0
-        self.data = []

+ 0 - 1
modules/led/dw_leds/utils/__init__.py

@@ -1 +0,0 @@
-"""WLED RPI utilities"""

+ 0 - 234
modules/led/dw_leds/utils/colors.py

@@ -1,234 +0,0 @@
-#!/usr/bin/env python3
-"""
-WLED Color Utilities for Raspberry Pi
-Ported from WLED colors.cpp
-"""
-import math
-from typing import Tuple
-
-# Color manipulation functions
-
-def color_blend(color1: int, color2: int, blend: int) -> int:
-    """
-    Blend two colors together (32-bit WRGB format)
-    blend: 0-255 (0 = full color1, 255 = full color2)
-    """
-    if blend == 0:
-        return color1
-    if blend == 255:
-        return color2
-
-    w1 = (color1 >> 24) & 0xFF
-    r1 = (color1 >> 16) & 0xFF
-    g1 = (color1 >> 8) & 0xFF
-    b1 = color1 & 0xFF
-
-    w2 = (color2 >> 24) & 0xFF
-    r2 = (color2 >> 16) & 0xFF
-    g2 = (color2 >> 8) & 0xFF
-    b2 = color2 & 0xFF
-
-    w3 = ((w1 * (255 - blend)) + (w2 * blend)) // 255
-    r3 = ((r1 * (255 - blend)) + (r2 * blend)) // 255
-    g3 = ((g1 * (255 - blend)) + (g2 * blend)) // 255
-    b3 = ((b1 * (255 - blend)) + (b2 * blend)) // 255
-
-    return (w3 << 24) | (r3 << 16) | (g3 << 8) | b3
-
-def color_add(c1: int, c2: int, preserve_ratio: bool = False) -> int:
-    """Add two colors together with optional ratio preservation"""
-    if c1 == 0:
-        return c2
-    if c2 == 0:
-        return c1
-
-    w1 = (c1 >> 24) & 0xFF
-    r1 = (c1 >> 16) & 0xFF
-    g1 = (c1 >> 8) & 0xFF
-    b1 = c1 & 0xFF
-
-    w2 = (c2 >> 24) & 0xFF
-    r2 = (c2 >> 16) & 0xFF
-    g2 = (c2 >> 8) & 0xFF
-    b2 = c2 & 0xFF
-
-    r = min(255, r1 + r2)
-    g = min(255, g1 + g2)
-    b = min(255, b1 + b2)
-    w = min(255, w1 + w2)
-
-    if preserve_ratio:
-        max_val = max(r, g, b, w)
-        if max_val > 255:
-            scale = 255.0 / max_val
-            r = int(r * scale)
-            g = int(g * scale)
-            b = int(b * scale)
-            w = int(w * scale)
-
-    return (w << 24) | (r << 16) | (g << 8) | b
-
-def color_fade(color: int, amount: int, video: bool = False) -> int:
-    """
-    Fade color toward black
-    amount: 0 (black) to 255 (no fade)
-    video: if True, uses "video" scaling (never goes to pure black)
-    """
-    if color == 0 or amount == 0:
-        return 0
-    if amount == 255:
-        return color
-
-    w = (color >> 24) & 0xFF
-    r = (color >> 16) & 0xFF
-    g = (color >> 8) & 0xFF
-    b = color & 0xFF
-
-    if not video:
-        amount += 1
-        w = (w * amount) >> 8
-        r = (r * amount) >> 8
-        g = (g * amount) >> 8
-        b = (b * amount) >> 8
-    else:
-        # Video scaling - ensure colors don't go to zero if they started non-zero
-        w = max(1 if w else 0, (w * amount) >> 8)
-        r = max(1 if r else 0, (r * amount) >> 8)
-        g = max(1 if g else 0, (g * amount) >> 8)
-        b = max(1 if b else 0, (b * amount) >> 8)
-
-    return (w << 24) | (r << 16) | (g << 8) | b
-
-def wheel(pos: int) -> Tuple[int, int, int]:
-    """
-    Color wheel function (0-255) -> RGB
-    Used for rainbow effects
-    """
-    pos &= 0xFF
-    if pos < 85:
-        return (pos * 3, 255 - pos * 3, 0)
-    elif pos < 170:
-        pos -= 85
-        return (255 - pos * 3, 0, pos * 3)
-    else:
-        pos -= 170
-        return (0, pos * 3, 255 - pos * 3)
-
-def color_wheel(pos: int) -> int:
-    """Color wheel returning 32-bit color (WLED format)"""
-    r, g, b = wheel(pos)
-    return (r << 16) | (g << 8) | b
-
-def hsv_to_rgb(h: int, s: int, v: int) -> Tuple[int, int, int]:
-    """
-    Convert HSV to RGB
-    h: 0-65535 (16-bit hue)
-    s: 0-255 (saturation)
-    v: 0-255 (value/brightness)
-    Returns: (r, g, b) tuple, each 0-255
-    """
-    if s == 0:
-        return (v, v, v)
-
-    region = h // 10923  # 65536 / 6
-    remainder = (h - (region * 10923)) * 6
-
-    p = (v * (255 - s)) >> 8
-    q = (v * (255 - ((s * remainder) >> 16))) >> 8
-    t = (v * (255 - ((s * (65535 - remainder)) >> 16))) >> 8
-
-    if region == 0:
-        return (v, t, p)
-    elif region == 1:
-        return (q, v, p)
-    elif region == 2:
-        return (p, v, t)
-    elif region == 3:
-        return (p, q, v)
-    elif region == 4:
-        return (t, p, v)
-    else:
-        return (v, p, q)
-
-def rgb_to_hsv(r: int, g: int, b: int) -> Tuple[int, int, int]:
-    """
-    Convert RGB to HSV
-    Returns: (h, s, v) where h is 0-65535, s and v are 0-255
-    """
-    min_val = min(r, g, b)
-    max_val = max(r, g, b)
-
-    if max_val == 0:
-        return (0, 0, 0)
-
-    v = max_val
-    delta = max_val - min_val
-    s = (255 * delta) // max_val
-
-    if s == 0:
-        return (0, 0, v)
-
-    if max_val == r:
-        h = (10923 * (g - b)) // delta
-    elif max_val == g:
-        h = 21845 + (10923 * (b - r)) // delta
-    else:
-        h = 43690 + (10923 * (r - g)) // delta
-
-    h &= 0xFFFF  # Ensure 16-bit
-    return (h, s, v)
-
-def sin8(x: int) -> int:
-    """Fast 8-bit sine approximation (0-255 input, 0-255 output)"""
-    # Simple sine approximation
-    x &= 0xFF
-    if x < 128:
-        # Rising half
-        return int(128 + 127 * math.sin(x * math.pi / 128))
-    else:
-        # Falling half
-        return int(128 + 127 * math.sin((x - 128) * math.pi / 128))
-
-def sin16(x: int) -> int:
-    """16-bit sine (-32768 to 32767 output)"""
-    angle = (x & 0xFFFF) / 65536.0 * 2 * math.pi
-    return int(32767 * math.sin(angle))
-
-def triwave16(x: int) -> int:
-    """Triangle wave 0-65535"""
-    x &= 0xFFFF
-    if x < 0x8000:
-        return x * 2
-    return 0xFFFF - (x - 0x8000) * 2
-
-# Helper functions to extract color components
-def get_r(color: int) -> int:
-    """Extract red component from 32-bit color"""
-    return (color >> 16) & 0xFF
-
-def get_g(color: int) -> int:
-    """Extract green component from 32-bit color"""
-    return (color >> 8) & 0xFF
-
-def get_b(color: int) -> int:
-    """Extract blue component from 32-bit color"""
-    return color & 0xFF
-
-def get_w(color: int) -> int:
-    """Extract white component from 32-bit color"""
-    return (color >> 24) & 0xFF
-
-def rgb_to_color(r: int, g: int, b: int, w: int = 0) -> int:
-    """Create 32-bit color from RGBW components"""
-    return (w << 24) | (r << 16) | (g << 8) | b
-
-def color_from_tuple(rgb: Tuple[int, int, int]) -> int:
-    """Convert RGB tuple to 32-bit color"""
-    return (rgb[0] << 16) | (rgb[1] << 8) | rgb[2]
-
-# Constants
-BLACK = 0x00000000
-WHITE = 0x00FFFFFF
-RED = 0x00FF0000
-GREEN = 0x0000FF00
-BLUE = 0x000000FF

+ 0 - 766
modules/led/dw_leds/utils/palettes.py

@@ -1,766 +0,0 @@
-#!/usr/bin/env python3
-"""
-WLED Color Palettes for Raspberry Pi
-Ported from WLED palettes.cpp
-All 59 gradient palettes from WLED
-"""
-from typing import List, Tuple
-from .colors import color_blend, rgb_to_color
-
-# Gradient palette format: [(index, r, g, b), ...]
-# Index is 0-255 position in the palette
-
-# Gradient palettes (indices 13-71 in WLED, 0-58 here)
-
-SUNSET_REAL = [
-    (0, 181, 0, 0),
-    (22, 218, 85, 0),
-    (51, 255, 170, 0),
-    (85, 211, 85, 77),
-    (135, 167, 0, 169),
-    (198, 73, 0, 188),
-    (255, 0, 0, 207)
-]
-
-RIVENDELL = [
-    (0, 24, 69, 44),
-    (101, 73, 105, 70),
-    (165, 129, 140, 97),
-    (242, 200, 204, 166),
-    (255, 200, 204, 166)
-]
-
-BREEZE = [
-    (0, 16, 48, 51),
-    (89, 27, 166, 175),
-    (153, 197, 233, 255),
-    (255, 0, 145, 152)
-]
-
-RED_BLUE = [
-    (0, 41, 14, 99),
-    (31, 128, 24, 74),
-    (63, 227, 34, 50),
-    (95, 132, 31, 76),
-    (127, 47, 29, 102),
-    (159, 109, 47, 101),
-    (191, 176, 66, 100),
-    (223, 129, 57, 104),
-    (255, 84, 48, 108)
-]
-
-YELLOWOUT = [
-    (0, 222, 191, 8),
-    (255, 117, 52, 1)
-]
-
-ANALOGOUS = [
-    (0, 38, 0, 255),
-    (63, 86, 0, 255),
-    (127, 139, 0, 255),
-    (191, 196, 0, 117),
-    (255, 255, 0, 0)
-]
-
-SPLASH = [
-    (0, 186, 63, 255),
-    (127, 227, 9, 85),
-    (175, 234, 205, 213),
-    (221, 205, 38, 176),
-    (255, 205, 38, 176)
-]
-
-PASTEL = [
-    (0, 61, 135, 184),
-    (36, 129, 188, 169),
-    (87, 203, 241, 155),
-    (100, 228, 237, 141),
-    (107, 255, 232, 127),
-    (115, 251, 202, 130),
-    (120, 248, 172, 133),
-    (128, 251, 202, 130),
-    (180, 255, 232, 127),
-    (223, 255, 242, 120),
-    (255, 255, 252, 113)
-]
-
-SUNSET2 = [
-    (0, 175, 121, 62),
-    (29, 128, 103, 60),
-    (68, 84, 84, 58),
-    (68, 248, 184, 55),
-    (97, 239, 204, 93),
-    (124, 230, 225, 133),
-    (178, 102, 125, 129),
-    (255, 0, 26, 125)
-]
-
-BEECH = [
-    (0, 255, 254, 236),
-    (12, 255, 254, 236),
-    (22, 255, 254, 236),
-    (26, 223, 224, 178),
-    (28, 192, 195, 124),
-    (28, 176, 255, 231),
-    (50, 123, 251, 236),
-    (71, 74, 246, 241),
-    (93, 33, 225, 228),
-    (120, 0, 204, 215),
-    (133, 4, 168, 178),
-    (136, 10, 132, 143),
-    (136, 51, 189, 212),
-    (208, 23, 159, 201),
-    (255, 0, 129, 190)
-]
-
-VINTAGE = [
-    (0, 41, 18, 24),
-    (51, 73, 0, 22),
-    (76, 165, 170, 38),
-    (101, 255, 189, 80),
-    (127, 139, 56, 40),
-    (153, 73, 0, 22),
-    (229, 41, 18, 24),
-    (255, 41, 18, 24)
-]
-
-DEPARTURE = [
-    (0, 53, 34, 0),
-    (42, 86, 51, 0),
-    (63, 147, 108, 49),
-    (84, 212, 166, 108),
-    (106, 235, 212, 180),
-    (116, 255, 255, 255),
-    (138, 191, 255, 193),
-    (148, 84, 255, 88),
-    (170, 0, 255, 0),
-    (191, 0, 192, 0),
-    (212, 0, 128, 0),
-    (255, 0, 128, 0)
-]
-
-LANDSCAPE = [
-    (0, 0, 0, 0),
-    (37, 31, 89, 19),
-    (76, 72, 178, 43),
-    (127, 150, 235, 5),
-    (128, 186, 234, 119),
-    (130, 222, 233, 252),
-    (153, 197, 219, 231),
-    (204, 132, 179, 253),
-    (255, 28, 107, 225)
-]
-
-BEACH = [
-    (0, 12, 45, 0),
-    (19, 101, 86, 2),
-    (38, 207, 128, 4),
-    (63, 243, 197, 18),
-    (66, 109, 196, 146),
-    (255, 5, 39, 7)
-]
-
-SHERBET = [
-    (0, 255, 102, 41),
-    (43, 255, 140, 90),
-    (86, 255, 51, 90),
-    (127, 255, 153, 169),
-    (170, 255, 255, 249),
-    (209, 113, 255, 85),
-    (255, 157, 255, 137)
-]
-
-HULT = [
-    (0, 251, 216, 252),
-    (48, 255, 192, 255),
-    (89, 239, 95, 241),
-    (160, 51, 153, 217),
-    (216, 24, 184, 174),
-    (255, 24, 184, 174)
-]
-
-HULT64 = [
-    (0, 24, 184, 174),
-    (66, 8, 162, 150),
-    (104, 124, 137, 7),
-    (130, 178, 186, 22),
-    (150, 124, 137, 7),
-    (201, 6, 156, 144),
-    (239, 0, 128, 117),
-    (255, 0, 128, 117)
-]
-
-DRYWET = [
-    (0, 119, 97, 33),
-    (42, 235, 199, 88),
-    (84, 169, 238, 124),
-    (127, 37, 238, 232),
-    (170, 7, 120, 236),
-    (212, 27, 1, 175),
-    (255, 4, 51, 101)
-]
-
-JUL = [
-    (0, 226, 6, 12),
-    (94, 26, 96, 78),
-    (132, 130, 189, 94),
-    (255, 177, 3, 9)
-]
-
-GRINTAGE = [
-    (0, 29, 8, 3),
-    (53, 76, 1, 0),
-    (104, 142, 96, 28),
-    (153, 211, 191, 61),
-    (255, 117, 129, 42)
-]
-
-REWHI = [
-    (0, 177, 160, 199),
-    (72, 205, 158, 149),
-    (89, 233, 155, 101),
-    (107, 255, 95, 63),
-    (141, 192, 98, 109),
-    (255, 132, 101, 159)
-]
-
-TERTIARY = [
-    (0, 0, 25, 255),
-    (63, 38, 140, 117),
-    (127, 86, 255, 0),
-    (191, 167, 140, 19),
-    (255, 255, 25, 41)
-]
-
-FIRE = [
-    (0, 0, 0, 0),
-    (46, 77, 0, 0),
-    (96, 177, 0, 0),
-    (108, 196, 38, 9),
-    (119, 215, 76, 19),
-    (146, 235, 115, 29),
-    (174, 255, 153, 41),
-    (188, 255, 178, 41),
-    (202, 255, 204, 41),
-    (218, 255, 230, 41),
-    (234, 255, 255, 41),
-    (244, 255, 255, 143),
-    (255, 255, 255, 255)
-]
-
-ICEFIRE = [
-    (0, 0, 0, 0),
-    (59, 0, 51, 117),
-    (119, 0, 102, 255),
-    (149, 38, 153, 255),
-    (180, 86, 204, 255),
-    (217, 167, 230, 255),
-    (255, 255, 255, 255)
-]
-
-CYANE = [
-    (0, 61, 155, 44),
-    (25, 95, 174, 77),
-    (60, 132, 193, 113),
-    (93, 154, 166, 125),
-    (106, 175, 138, 136),
-    (109, 183, 121, 137),
-    (113, 194, 104, 138),
-    (116, 225, 179, 165),
-    (124, 255, 255, 192),
-    (168, 167, 218, 203),
-    (255, 84, 182, 215)
-]
-
-LIGHT_PINK = [
-    (0, 79, 32, 109),
-    (25, 90, 40, 117),
-    (51, 102, 48, 124),
-    (76, 141, 135, 185),
-    (102, 180, 222, 248),
-    (109, 208, 236, 252),
-    (114, 237, 250, 255),
-    (122, 206, 200, 239),
-    (149, 177, 149, 222),
-    (183, 187, 130, 203),
-    (255, 198, 111, 184)
-]
-
-AUTUMN = [
-    (0, 90, 14, 5),
-    (51, 139, 41, 13),
-    (84, 180, 70, 17),
-    (104, 192, 202, 125),
-    (112, 177, 137, 3),
-    (122, 190, 200, 131),
-    (124, 192, 202, 124),
-    (135, 177, 137, 3),
-    (142, 194, 203, 118),
-    (163, 177, 68, 17),
-    (204, 128, 35, 12),
-    (249, 74, 5, 2),
-    (255, 74, 5, 2)
-]
-
-MAGENTA = [
-    (0, 0, 0, 0),
-    (42, 0, 0, 117),
-    (84, 0, 0, 255),
-    (127, 113, 0, 255),
-    (170, 255, 0, 255),
-    (212, 255, 128, 255),
-    (255, 255, 255, 255)
-]
-
-MAGRED = [
-    (0, 0, 0, 0),
-    (63, 113, 0, 117),
-    (127, 255, 0, 255),
-    (191, 255, 0, 117),
-    (255, 255, 0, 0)
-]
-
-YELMAG = [
-    (0, 0, 0, 0),
-    (42, 113, 0, 0),
-    (84, 255, 0, 0),
-    (127, 255, 0, 117),
-    (170, 255, 0, 255),
-    (212, 255, 128, 117),
-    (255, 255, 255, 0)
-]
-
-YELBLU = [
-    (0, 0, 0, 255),
-    (63, 0, 128, 255),
-    (127, 0, 255, 255),
-    (191, 113, 255, 117),
-    (255, 255, 255, 0)
-]
-
-ORANGE_TEAL = [
-    (0, 0, 150, 92),
-    (55, 0, 150, 92),
-    (200, 255, 72, 0),
-    (255, 255, 72, 0)
-]
-
-TIAMAT = [
-    (0, 1, 2, 14),
-    (33, 2, 5, 35),
-    (100, 13, 135, 92),
-    (120, 43, 255, 193),
-    (140, 247, 7, 249),
-    (160, 193, 17, 208),
-    (180, 39, 255, 154),
-    (200, 4, 213, 236),
-    (220, 39, 252, 135),
-    (240, 193, 213, 253),
-    (255, 255, 249, 255)
-]
-
-APRIL_NIGHT = [
-    (0, 1, 5, 45),
-    (10, 1, 5, 45),
-    (25, 5, 169, 175),
-    (40, 1, 5, 45),
-    (61, 1, 5, 45),
-    (76, 45, 175, 31),
-    (91, 1, 5, 45),
-    (112, 1, 5, 45),
-    (127, 249, 150, 5),
-    (143, 1, 5, 45),
-    (162, 1, 5, 45),
-    (178, 255, 92, 0),
-    (193, 1, 5, 45),
-    (214, 1, 5, 45),
-    (229, 223, 45, 72),
-    (244, 1, 5, 45),
-    (255, 1, 5, 45)
-]
-
-ORANGERY = [
-    (0, 255, 95, 23),
-    (30, 255, 82, 0),
-    (60, 223, 13, 8),
-    (90, 144, 44, 2),
-    (120, 255, 110, 17),
-    (150, 255, 69, 0),
-    (180, 158, 13, 11),
-    (210, 241, 82, 17),
-    (255, 213, 37, 4)
-]
-
-C9 = [
-    (0, 184, 4, 0),
-    (60, 184, 4, 0),
-    (65, 144, 44, 2),
-    (125, 144, 44, 2),
-    (130, 4, 96, 2),
-    (190, 4, 96, 2),
-    (195, 7, 7, 88),
-    (255, 7, 7, 88)
-]
-
-SAKURA = [
-    (0, 196, 19, 10),
-    (65, 255, 69, 45),
-    (130, 223, 45, 72),
-    (195, 255, 82, 103),
-    (255, 223, 13, 17)
-]
-
-AURORA = [
-    (0, 1, 5, 45),
-    (64, 0, 200, 23),
-    (128, 0, 255, 0),
-    (170, 0, 243, 45),
-    (200, 0, 135, 7),
-    (255, 1, 5, 45)
-]
-
-ATLANTICA = [
-    (0, 0, 28, 112),
-    (50, 32, 96, 255),
-    (100, 0, 243, 45),
-    (150, 12, 95, 82),
-    (200, 25, 190, 95),
-    (255, 40, 170, 80)
-]
-
-C9_2 = [
-    (0, 6, 126, 2),
-    (45, 6, 126, 2),
-    (46, 4, 30, 114),
-    (90, 4, 30, 114),
-    (91, 255, 5, 0),
-    (135, 255, 5, 0),
-    (136, 196, 57, 2),
-    (180, 196, 57, 2),
-    (181, 137, 85, 2),
-    (255, 137, 85, 2)
-]
-
-C9_NEW = [
-    (0, 255, 5, 0),
-    (60, 255, 5, 0),
-    (61, 196, 57, 2),
-    (120, 196, 57, 2),
-    (121, 6, 126, 2),
-    (180, 6, 126, 2),
-    (181, 4, 30, 114),
-    (255, 4, 30, 114)
-]
-
-TEMPERATURE = [
-    (0, 20, 92, 171),
-    (14, 15, 111, 186),
-    (28, 6, 142, 211),
-    (42, 2, 161, 227),
-    (56, 16, 181, 239),
-    (70, 38, 188, 201),
-    (84, 86, 204, 200),
-    (99, 139, 219, 176),
-    (113, 182, 229, 125),
-    (127, 196, 230, 63),
-    (141, 241, 240, 22),
-    (155, 254, 222, 30),
-    (170, 251, 199, 4),
-    (184, 247, 157, 9),
-    (198, 243, 114, 15),
-    (226, 213, 30, 29),
-    (240, 151, 38, 35),
-    (255, 151, 38, 35)
-]
-
-AURORA2 = [
-    (0, 17, 177, 13),
-    (64, 121, 242, 5),
-    (128, 25, 173, 121),
-    (192, 250, 77, 127),
-    (255, 171, 101, 221)
-]
-
-RETRO_CLOWN = [
-    (0, 242, 168, 38),
-    (117, 226, 78, 80),
-    (255, 161, 54, 225)
-]
-
-CANDY = [
-    (0, 243, 242, 23),
-    (15, 242, 168, 38),
-    (142, 111, 21, 151),
-    (198, 74, 22, 150),
-    (255, 0, 0, 117)
-]
-
-TOXY_REAF = [
-    (0, 2, 239, 126),
-    (255, 145, 35, 217)
-]
-
-FAIRY_REAF = [
-    (0, 220, 19, 187),
-    (160, 12, 225, 219),
-    (219, 203, 242, 223),
-    (255, 255, 255, 255)
-]
-
-SEMI_BLUE = [
-    (0, 0, 0, 0),
-    (12, 24, 4, 38),
-    (53, 55, 8, 84),
-    (80, 43, 48, 159),
-    (119, 31, 89, 237),
-    (145, 50, 59, 166),
-    (186, 71, 30, 98),
-    (233, 31, 15, 45),
-    (255, 0, 0, 0)
-]
-
-PINK_CANDY = [
-    (0, 255, 255, 255),
-    (45, 50, 64, 255),
-    (112, 242, 16, 186),
-    (140, 255, 255, 255),
-    (155, 242, 16, 186),
-    (196, 116, 13, 166),
-    (255, 255, 255, 255)
-]
-
-RED_REAF = [
-    (0, 36, 68, 114),
-    (104, 149, 195, 248),
-    (188, 255, 0, 0),
-    (255, 94, 14, 9)
-]
-
-AQUA_FLASH = [
-    (0, 0, 0, 0),
-    (66, 130, 242, 245),
-    (96, 255, 255, 53),
-    (124, 255, 255, 255),
-    (153, 255, 255, 53),
-    (188, 130, 242, 245),
-    (255, 0, 0, 0)
-]
-
-YELBLU_HOT = [
-    (0, 43, 30, 57),
-    (58, 73, 0, 119),
-    (122, 87, 0, 74),
-    (158, 197, 57, 22),
-    (183, 218, 117, 27),
-    (219, 239, 177, 32),
-    (255, 246, 247, 27)
-]
-
-LITE_LIGHT = [
-    (0, 0, 0, 0),
-    (9, 20, 21, 22),
-    (40, 46, 43, 49),
-    (66, 46, 43, 49),
-    (101, 61, 16, 65),
-    (255, 0, 0, 0)
-]
-
-RED_FLASH = [
-    (0, 0, 0, 0),
-    (99, 242, 12, 8),
-    (130, 253, 228, 163),
-    (155, 242, 12, 8),
-    (255, 0, 0, 0)
-]
-
-BLINK_RED = [
-    (0, 4, 7, 4),
-    (43, 40, 25, 62),
-    (76, 61, 15, 36),
-    (109, 207, 39, 96),
-    (127, 255, 156, 184),
-    (165, 185, 73, 207),
-    (204, 105, 66, 240),
-    (255, 77, 29, 78)
-]
-
-RED_SHIFT = [
-    (0, 98, 22, 93),
-    (45, 103, 22, 73),
-    (99, 192, 45, 56),
-    (132, 235, 187, 59),
-    (175, 228, 85, 26),
-    (201, 228, 56, 48),
-    (255, 2, 0, 2)
-]
-
-RED_TIDE = [
-    (0, 251, 46, 0),
-    (28, 255, 139, 25),
-    (43, 246, 158, 63),
-    (58, 246, 216, 123),
-    (84, 243, 94, 10),
-    (114, 177, 65, 11),
-    (140, 255, 241, 115),
-    (168, 177, 65, 11),
-    (196, 250, 233, 158),
-    (216, 255, 94, 6),
-    (255, 126, 8, 4)
-]
-
-CANDY2 = [
-    (0, 109, 102, 102),
-    (25, 42, 49, 71),
-    (48, 121, 96, 84),
-    (73, 241, 214, 26),
-    (89, 216, 104, 44),
-    (130, 42, 49, 71),
-    (163, 255, 177, 47),
-    (186, 241, 214, 26),
-    (211, 109, 102, 102),
-    (255, 20, 19, 13)
-]
-
-TRAFFIC_LIGHT = [
-    (0, 0, 0, 0),
-    (85, 0, 255, 0),
-    (170, 255, 255, 0),
-    (255, 255, 0, 0)
-]
-
-# All palettes in order (matching WLED indices 13-71, stored as 0-58 here)
-ALL_PALETTES = [
-    SUNSET_REAL,      # 0 (WLED 13)
-    RIVENDELL,        # 1
-    BREEZE,           # 2
-    RED_BLUE,         # 3
-    YELLOWOUT,        # 4
-    ANALOGOUS,        # 5
-    SPLASH,           # 6
-    PASTEL,           # 7
-    SUNSET2,          # 8
-    BEECH,            # 9
-    VINTAGE,          # 10
-    DEPARTURE,        # 11
-    LANDSCAPE,        # 12
-    BEACH,            # 13
-    SHERBET,          # 14
-    HULT,             # 15
-    HULT64,           # 16
-    DRYWET,           # 17
-    JUL,              # 18
-    GRINTAGE,         # 19
-    REWHI,            # 20
-    TERTIARY,         # 21
-    FIRE,             # 22
-    ICEFIRE,          # 23
-    CYANE,            # 24
-    LIGHT_PINK,       # 25
-    AUTUMN,           # 26
-    MAGENTA,          # 27
-    MAGRED,           # 28
-    YELMAG,           # 29
-    YELBLU,           # 30
-    ORANGE_TEAL,      # 31
-    TIAMAT,           # 32
-    APRIL_NIGHT,      # 33
-    ORANGERY,         # 34
-    C9,               # 35
-    SAKURA,           # 36
-    AURORA,           # 37
-    ATLANTICA,        # 38
-    C9_2,             # 39
-    C9_NEW,           # 40
-    TEMPERATURE,      # 41
-    AURORA2,          # 42
-    RETRO_CLOWN,      # 43
-    CANDY,            # 44
-    TOXY_REAF,        # 45
-    FAIRY_REAF,       # 46
-    SEMI_BLUE,        # 47
-    PINK_CANDY,       # 48
-    RED_REAF,         # 49
-    AQUA_FLASH,       # 50
-    YELBLU_HOT,       # 51
-    LITE_LIGHT,       # 52
-    RED_FLASH,        # 53
-    BLINK_RED,        # 54
-    RED_SHIFT,        # 55
-    RED_TIDE,         # 56
-    CANDY2,           # 57
-    TRAFFIC_LIGHT     # 58
-]
-
-PALETTE_NAMES = [
-    "Sunset", "Rivendell", "Breeze", "Red & Blue", "Yellowout",
-    "Analogous", "Splash", "Pastel", "Sunset2", "Beech",
-    "Vintage", "Departure", "Landscape", "Beach", "Sherbet",
-    "Hult", "Hult64", "Drywet", "Jul", "Grintage",
-    "Rewhi", "Tertiary", "Fire", "Icefire", "Cyane",
-    "Light Pink", "Autumn", "Magenta", "Magred", "Yelmag",
-    "Yelblu", "Orange & Teal", "Tiamat", "April Night", "Orangery",
-    "C9", "Sakura", "Aurora", "Atlantica", "C9 2",
-    "C9 New", "Temperature", "Aurora 2", "Retro Clown", "Candy",
-    "Toxy Reaf", "Fairy Reaf", "Semi Blue", "Pink Candy", "Red Reaf",
-    "Aqua Flash", "Yelblu Hot", "Lite Light", "Red Flash", "Blink Red",
-    "Red Shift", "Red Tide", "Candy2", "Traffic Light"
-]
-
-
-def color_from_palette(palette: List[Tuple[int, int, int, int]],
-                       index: int,
-                       brightness: int = 255) -> int:
-    """
-    Get color from gradient palette at given index
-    index: 0-255 position in palette
-    brightness: 0-255 brightness scaling
-    Returns: 32-bit RGB color
-    """
-    index = index & 0xFF
-
-    # Find the two palette entries to blend between
-    for i in range(len(palette) - 1):
-        if index <= palette[i + 1][0]:
-            idx1, r1, g1, b1 = palette[i]
-            idx2, r2, g2, b2 = palette[i + 1]
-
-            # Calculate blend amount
-            if idx2 == idx1:
-                blend = 0
-            else:
-                blend = ((index - idx1) * 255) // (idx2 - idx1)
-
-            # Blend colors
-            r = ((r1 * (255 - blend)) + (r2 * blend)) // 255
-            g = ((g1 * (255 - blend)) + (g2 * blend)) // 255
-            b = ((b1 * (255 - blend)) + (b2 * blend)) // 255
-
-            # Apply brightness
-            if brightness < 255:
-                r = (r * brightness) // 255
-                g = (g * brightness) // 255
-                b = (b * brightness) // 255
-
-            return rgb_to_color(r, g, b)
-
-    # If we're past the last entry, use the last color
-    idx, r, g, b = palette[-1]
-    if brightness < 255:
-        r = (r * brightness) // 255
-        g = (g * brightness) // 255
-        b = (b * brightness) // 255
-    return rgb_to_color(r, g, b)
-
-
-def get_palette(palette_id: int) -> List[Tuple[int, int, int, int]]:
-    """Get palette by ID (0-58)"""
-    if 0 <= palette_id < len(ALL_PALETTES):
-        return ALL_PALETTES[palette_id]
-    return ALL_PALETTES[0]  # Default to Sunset
-
-
-def get_palette_name(palette_id: int) -> str:
-    """Get palette name by ID"""
-    if 0 <= palette_id < len(PALETTE_NAMES):
-        return PALETTE_NAMES[palette_id]
-    return "Unknown"

+ 144 - 177
modules/led/led_interface.py

@@ -1,177 +1,144 @@
-"""
-Unified LED interface for different LED control systems
-Provides a common abstraction layer for pattern manager integration.
-"""
-import asyncio
-from typing import Optional, Literal
-from modules.led.led_controller import LEDController, effect_loading as wled_loading, effect_idle as wled_idle, effect_connected as wled_connected, effect_playing as wled_playing
-
-# Try to import DW LED controller - it requires RPi-specific dependencies
-try:
-    from modules.led.dw_led_controller import DWLEDController, effect_loading as dw_led_loading, effect_idle as dw_led_idle, effect_connected as dw_led_connected, effect_playing as dw_led_playing
-    DW_LEDS_AVAILABLE = True
-except ImportError:
-    # Running on non-RPi platform - DW LEDs not available
-    DWLEDController = None
-    dw_led_loading = None
-    dw_led_idle = None
-    dw_led_connected = None
-    dw_led_playing = None
-    DW_LEDS_AVAILABLE = False
-
-
-LEDProviderType = Literal["wled", "dw_leds", "none"]
-
-
-class LEDInterface:
-    """
-    Unified interface for LED control that works with multiple backends.
-    Automatically delegates to the appropriate controller based on configuration.
-    """
-
-    def __init__(self, provider: LEDProviderType = "none", ip_address: Optional[str] = None,
-                 num_leds: Optional[int] = None, gpio_pin: Optional[int] = None, pixel_order: Optional[str] = None,
-                 brightness: Optional[float] = None, speed: Optional[int] = None, intensity: Optional[int] = None):
-        self.provider = provider
-        self._controller = None
-
-        if provider == "wled" and ip_address:
-            self._controller = LEDController(ip_address)
-        elif provider == "dw_leds":
-            if not DW_LEDS_AVAILABLE:
-                raise ImportError("DW LED controller requires Raspberry Pi GPIO libraries. Install with: pip install -r requirements.txt")
-            # DW LEDs uses local GPIO, no IP needed
-            num_leds = num_leds or 60
-            gpio_pin = gpio_pin or 12
-            pixel_order = pixel_order or "GRB"
-            brightness = brightness if brightness is not None else 0.35
-            speed = speed if speed is not None else 128
-            intensity = intensity if intensity is not None else 128
-            self._controller = DWLEDController(num_leds, gpio_pin, brightness, pixel_order=pixel_order, speed=speed, intensity=intensity)
-
-    @property
-    def is_configured(self) -> bool:
-        """Check if LED controller is configured"""
-        return self._controller is not None
-
-    def update_config(self, provider: LEDProviderType, ip_address: Optional[str] = None,
-                     num_leds: Optional[int] = None, gpio_pin: Optional[int] = None, pixel_order: Optional[str] = None,
-                     brightness: Optional[float] = None, speed: Optional[int] = None, intensity: Optional[int] = None):
-        """Update LED provider configuration"""
-        self.provider = provider
-
-        # Stop existing controller if switching providers
-        if self._controller and hasattr(self._controller, 'stop'):
-            try:
-                self._controller.stop()
-            except:
-                pass
-
-        if provider == "wled" and ip_address:
-            self._controller = LEDController(ip_address)
-        elif provider == "dw_leds":
-            if not DW_LEDS_AVAILABLE:
-                raise ImportError("DW LED controller requires Raspberry Pi GPIO libraries. Install with: pip install -r requirements.txt")
-            num_leds = num_leds or 60
-            gpio_pin = gpio_pin or 12
-            pixel_order = pixel_order or "GRB"
-            brightness = brightness if brightness is not None else 0.35
-            speed = speed if speed is not None else 128
-            intensity = intensity if intensity is not None else 128
-            self._controller = DWLEDController(num_leds, gpio_pin, brightness, pixel_order=pixel_order, speed=speed, intensity=intensity)
-        else:
-            self._controller = None
-
-    def effect_loading(self) -> bool:
-        """Show loading effect"""
-        if not self.is_configured:
-            return False
-
-        if self.provider == "wled":
-            return wled_loading(self._controller)
-        elif self.provider == "dw_leds":
-            return dw_led_loading(self._controller)
-        return False
-
-    def effect_idle(self, effect_name: Optional[str] = None) -> bool:
-        """Show idle effect"""
-        if not self.is_configured:
-            return False
-
-        if self.provider == "wled":
-            return wled_idle(self._controller)
-        elif self.provider == "dw_leds":
-            return dw_led_idle(self._controller, effect_name)
-        return False
-
-    def effect_connected(self) -> bool:
-        """Show connected effect"""
-        if not self.is_configured:
-            return False
-
-        if self.provider == "wled":
-            return wled_connected(self._controller)
-        elif self.provider == "dw_leds":
-            return dw_led_connected(self._controller)
-        return False
-
-    def effect_playing(self, effect_name: Optional[str] = None) -> bool:
-        """Show playing effect"""
-        if not self.is_configured:
-            return False
-
-        if self.provider == "wled":
-            return wled_playing(self._controller)
-        elif self.provider == "dw_leds":
-            return dw_led_playing(self._controller, effect_name)
-        return False
-
-    def set_power(self, state: int) -> dict:
-        """Set power state (0=Off, 1=On, 2=Toggle)"""
-        if not self.is_configured:
-            return {"connected": False, "message": "No LED controller configured"}
-
-        return self._controller.set_power(state)
-
-    def check_status(self) -> dict:
-        """Check controller status"""
-        if not self.is_configured:
-            return {"connected": False, "message": "No LED controller configured"}
-
-        if self.provider == "wled":
-            return self._controller.check_wled_status()
-        elif self.provider == "dw_leds":
-            return self._controller.check_status()
-
-        return {"connected": False, "message": "Unknown provider"}
-
-    def get_controller(self):
-        """Get the underlying controller instance (for advanced usage)"""
-        return self._controller
-
-    # Async versions of methods for non-blocking calls from async context
-    # These use asyncio.to_thread() to avoid blocking the event loop
-
-    async def effect_loading_async(self) -> bool:
-        """Show loading effect (non-blocking)"""
-        return await asyncio.to_thread(self.effect_loading)
-
-    async def effect_idle_async(self, effect_name: Optional[str] = None) -> bool:
-        """Show idle effect (non-blocking)"""
-        return await asyncio.to_thread(self.effect_idle, effect_name)
-
-    async def effect_connected_async(self) -> bool:
-        """Show connected effect (non-blocking)"""
-        return await asyncio.to_thread(self.effect_connected)
-
-    async def effect_playing_async(self, effect_name: Optional[str] = None) -> bool:
-        """Show playing effect (non-blocking)"""
-        return await asyncio.to_thread(self.effect_playing, effect_name)
-
-    async def set_power_async(self, state: int) -> dict:
-        """Set power state (non-blocking)"""
-        return await asyncio.to_thread(self.set_power, state)
-
-    async def check_status_async(self) -> dict:
-        """Check controller status (non-blocking)"""
-        return await asyncio.to_thread(self.check_status)
+"""
+Unified LED interface for different LED control systems
+Provides a common abstraction layer for pattern manager integration.
+"""
+import asyncio
+from typing import Optional, Literal
+from modules.led.led_controller import LEDController, effect_loading as wled_loading, effect_idle as wled_idle, effect_connected as wled_connected, effect_playing as wled_playing
+from modules.led.board_led_controller import BoardLEDController
+
+LEDProviderType = Literal["wled", "board", "none"]
+
+
+class LEDInterface:
+    """
+    Unified interface for LED control that works with multiple backends.
+    Automatically delegates to the appropriate controller based on configuration.
+    """
+
+    def __init__(self, provider: LEDProviderType = "none", ip_address: Optional[str] = None,
+                 num_leds: Optional[int] = None, gpio_pin: Optional[int] = None, pixel_order: Optional[str] = None,
+                 brightness: Optional[float] = None, speed: Optional[int] = None, intensity: Optional[int] = None):
+        self.provider = provider
+        self._controller = None
+
+        if provider == "wled" and ip_address:
+            self._controller = LEDController(ip_address)
+        elif provider == "board":
+            # The table's own LED ring, driven by the FluidNC firmware.
+            self._controller = BoardLEDController()
+
+    @property
+    def is_configured(self) -> bool:
+        """Check if LED controller is configured"""
+        return self._controller is not None
+
+    def update_config(self, provider: LEDProviderType, ip_address: Optional[str] = None,
+                     num_leds: Optional[int] = None, gpio_pin: Optional[int] = None, pixel_order: Optional[str] = None,
+                     brightness: Optional[float] = None, speed: Optional[int] = None, intensity: Optional[int] = None):
+        """Update LED provider configuration"""
+        self.provider = provider
+
+        # Stop existing controller if switching providers
+        if self._controller and hasattr(self._controller, 'stop'):
+            try:
+                self._controller.stop()
+            except:
+                pass
+
+        if provider == "wled" and ip_address:
+            self._controller = LEDController(ip_address)
+        elif provider == "board":
+            self._controller = BoardLEDController()
+        else:
+            self._controller = None
+
+    # NOTE: for the "board" provider the effect_* transition hooks below fall
+    # through to False on purpose — the firmware switches run/idle effects
+    # itself ($LED/RunEffect / $LED/IdleEffect); the host must not fight it.
+
+    def effect_loading(self) -> bool:
+        """Show loading effect"""
+        if not self.is_configured:
+            return False
+
+        if self.provider == "wled":
+            return wled_loading(self._controller)
+        return False
+
+    def effect_idle(self, effect_name: Optional[str] = None) -> bool:
+        """Show idle effect"""
+        if not self.is_configured:
+            return False
+
+        if self.provider == "wled":
+            return wled_idle(self._controller)
+        return False
+
+    def effect_connected(self) -> bool:
+        """Show connected effect"""
+        if not self.is_configured:
+            return False
+
+        if self.provider == "wled":
+            return wled_connected(self._controller)
+        return False
+
+    def effect_playing(self, effect_name: Optional[str] = None) -> bool:
+        """Show playing effect"""
+        if not self.is_configured:
+            return False
+
+        if self.provider == "wled":
+            return wled_playing(self._controller)
+        return False
+
+    def set_power(self, state: int) -> dict:
+        """Set power state (0=Off, 1=On, 2=Toggle)"""
+        if not self.is_configured:
+            return {"connected": False, "message": "No LED controller configured"}
+
+        return self._controller.set_power(state)
+
+    def check_status(self) -> dict:
+        """Check controller status"""
+        if not self.is_configured:
+            return {"connected": False, "message": "No LED controller configured"}
+
+        if self.provider == "wled":
+            return self._controller.check_wled_status()
+        elif self.provider == "board":
+            return self._controller.check_status()
+
+        return {"connected": False, "message": "Unknown provider"}
+
+    def get_controller(self):
+        """Get the underlying controller instance (for advanced usage)"""
+        return self._controller
+
+    # Async versions of methods for non-blocking calls from async context
+    # These use asyncio.to_thread() to avoid blocking the event loop
+
+    async def effect_loading_async(self) -> bool:
+        """Show loading effect (non-blocking)"""
+        return await asyncio.to_thread(self.effect_loading)
+
+    async def effect_idle_async(self, effect_name: Optional[str] = None) -> bool:
+        """Show idle effect (non-blocking)"""
+        return await asyncio.to_thread(self.effect_idle, effect_name)
+
+    async def effect_connected_async(self) -> bool:
+        """Show connected effect (non-blocking)"""
+        return await asyncio.to_thread(self.effect_connected)
+
+    async def effect_playing_async(self, effect_name: Optional[str] = None) -> bool:
+        """Show playing effect (non-blocking)"""
+        return await asyncio.to_thread(self.effect_playing, effect_name)
+
+    async def set_power_async(self, state: int) -> dict:
+        """Set power state (non-blocking)"""
+        return await asyncio.to_thread(self.set_power, state)
+
+    async def check_status_async(self) -> dict:
+        """Check controller status (non-blocking)"""
+        return await asyncio.to_thread(self.check_status)

+ 1011 - 1011
modules/mqtt/handler.py

@@ -1,1012 +1,1012 @@
-"""Real MQTT handler implementation."""
-import os
-import threading
-import time
-import json
-import uuid
-from typing import Dict, Callable
-import paho.mqtt.client as mqtt
-import logging
-import asyncio
-
-from .base import BaseMQTTHandler
-from modules.core.state import state
-from modules.core.pattern_manager import list_theta_rho_files
-from modules.core.playlist_manager import list_all_playlists
-
-logger = logging.getLogger(__name__)
-
-class MQTTHandler(BaseMQTTHandler):
-    """Real implementation of MQTT handler."""
-
-    def __init__(self, callback_registry: Dict[str, Callable]):
-        # MQTT Configuration - prioritize state config over environment variables
-        # This allows UI configuration to override .env settings
-        self.broker = state.mqtt_broker if state.mqtt_broker else os.getenv('MQTT_BROKER')
-        self.port = state.mqtt_port if state.mqtt_port else int(os.getenv('MQTT_PORT', '1883'))
-        self.username = state.mqtt_username if state.mqtt_username else os.getenv('MQTT_USERNAME')
-        self.password = state.mqtt_password if state.mqtt_password else os.getenv('MQTT_PASSWORD')
-        self.status_topic = os.getenv('MQTT_STATUS_TOPIC', 'dune_weaver/status')
-        self.command_topic = os.getenv('MQTT_COMMAND_TOPIC', 'dune_weaver/command')
-        self.status_interval = int(os.getenv('MQTT_STATUS_INTERVAL', '30'))
-
-        # Store callback registry
-        self.callback_registry = callback_registry
-
-        # Threading control
-        self.running = False
-        self.status_thread = None
-
-        # Home Assistant MQTT Discovery settings - prioritize state config
-        self.discovery_prefix = state.mqtt_discovery_prefix if state.mqtt_discovery_prefix else os.getenv('MQTT_DISCOVERY_PREFIX', 'homeassistant')
-        self.device_name = state.mqtt_device_name if state.mqtt_device_name else os.getenv('HA_DEVICE_NAME', 'Dune Weaver')
-        self.device_id = state.mqtt_device_id if state.mqtt_device_id else os.getenv('HA_DEVICE_ID', 'dune_weaver')
-
-        # MQTT broker-level client identity. If the user hasn't set one explicitly,
-        # generate a random suffix so two instances can never collide on the same
-        # client_id and kick each other off the broker in a reconnect loop. The
-        # device_id prefix keeps the random ID greppable in broker logs.
-        self.client_id = (
-            state.mqtt_client_id
-            or os.getenv('MQTT_CLIENT_ID')
-            or f"{self.device_id}-{uuid.uuid4().hex[:8]}"
-        )
-
-        # Additional topics for state
-        self.running_state_topic = f"{self.device_id}/state/running"
-        self.serial_state_topic = f"{self.device_id}/state/serial"
-        self.pattern_select_topic = f"{self.device_id}/pattern/set"
-        self.playlist_select_topic = f"{self.device_id}/playlist/set"
-        self.speed_topic = f"{self.device_id}/speed/set"
-        self.completion_topic = f"{self.device_id}/state/completion"
-        self.time_remaining_topic = f"{self.device_id}/state/time_remaining"
-
-        # Screen control topics
-        self.screen_power_topic = f"{self.device_id}/screen/power/set"
-        self.screen_brightness_topic = f"{self.device_id}/screen/brightness/set"
-
-        # LED control topics
-        self.led_power_topic = f"{self.device_id}/led/power/set"
-        self.led_brightness_topic = f"{self.device_id}/led/brightness/set"
-        self.led_effect_topic = f"{self.device_id}/led/effect/set"
-        self.led_speed_topic = f"{self.device_id}/led/speed/set"
-        self.led_intensity_topic = f"{self.device_id}/led/intensity/set"
-        self.led_color_topic = f"{self.device_id}/led/color/set"
-
-        # Store current state
-        self.current_file = ""
-        self.is_running_state = False
-        self.serial_state = ""
-        self.patterns = []
-        self.playlists = []
-
-        # Track connection state
-        self._connected = False
-
-        # Initialize MQTT client if broker is configured
-        if self.broker:
-            self.client = mqtt.Client(client_id=self.client_id)
-            self.client.on_connect = self.on_connect
-            self.client.on_disconnect = self.on_disconnect
-            self.client.on_message = self.on_message
-
-            if self.username and self.password:
-                self.client.username_pw_set(self.username, self.password)
-
-        self.state = state
-        self.state.mqtt_handler = self  # Set reference to self in state, needed so that state setters can update the state
-
-        # Store the main event loop during initialization
-        self.main_loop = asyncio.get_event_loop()
-
-    def setup_ha_discovery(self):
-        """Publish Home Assistant MQTT discovery configurations."""
-        if not self.is_enabled:
-            return
-
-        base_device = {
-            "identifiers": [self.device_id],
-            "name": self.device_name,
-            "model": "Dune Weaver",
-            "manufacturer": "DIY"
-        }
-        
-        # Serial State Sensor
-        serial_config = {
-            "name": f"{self.device_name} Serial State",
-            "unique_id": f"{self.device_id}_serial_state",
-            "state_topic": self.serial_state_topic,
-            "device": base_device,
-            "icon": "mdi:serial-port",
-            "entity_category": "diagnostic"
-        }
-        self._publish_discovery("sensor", "serial_state", serial_config)
-
-        # Running State Sensor
-        running_config = {
-            "name": f"{self.device_name} Running State",
-            "unique_id": f"{self.device_id}_running_state",
-            "state_topic": self.running_state_topic,
-            "device": base_device,
-            "icon": "mdi:machine",
-            "entity_category": "diagnostic"
-        }
-        self._publish_discovery("sensor", "running_state", running_config)
-
-        # Stop Button
-        stop_config = {
-            "name": "Stop pattern execution",
-            "unique_id": f"{self.device_id}_stop",
-            "command_topic": f"{self.device_id}/command/stop",
-            "device": base_device,
-            "icon": "mdi:stop",
-            "entity_category": "config"
-        }
-        self._publish_discovery("button", "stop", stop_config)
-
-        # Pause Button
-        pause_config = {
-            "name": "Pause pattern execution",
-            "unique_id": f"{self.device_id}_pause",
-            "command_topic": f"{self.device_id}/command/pause",
-            "state_topic": f"{self.device_id}/command/pause/state",
-            "device": base_device,
-            "icon": "mdi:pause",
-            "entity_category": "config",
-            "enabled_by_default": True,
-            "availability": {
-                "topic": f"{self.device_id}/command/pause/available",
-                "payload_available": "true",
-                "payload_not_available": "false"
-            }
-        }
-        self._publish_discovery("button", "pause", pause_config)
-
-        # Play Button
-        play_config = {
-            "name": "Resume pattern execution",
-            "unique_id": f"{self.device_id}_play",
-            "command_topic": f"{self.device_id}/command/play",
-            "state_topic": f"{self.device_id}/command/play/state",
-            "device": base_device,
-            "icon": "mdi:play",
-            "entity_category": "config",
-            "enabled_by_default": True,
-            "availability": {
-                "topic": f"{self.device_id}/command/play/available",
-                "payload_available": "true",
-                "payload_not_available": "false"
-            }
-        }
-        self._publish_discovery("button", "play", play_config)
-
-        # Skip Button
-        skip_config = {
-            "name": "Skip to next pattern",
-            "unique_id": f"{self.device_id}_skip",
-            "command_topic": f"{self.device_id}/command/skip",
-            "device": base_device,
-            "icon": "mdi:skip-next",
-            "entity_category": "config",
-            "enabled_by_default": True,
-            "availability": {
-                "topic": f"{self.device_id}/command/skip/available",
-                "payload_available": "true",
-                "payload_not_available": "false"
-            }
-        }
-        self._publish_discovery("button", "skip", skip_config)
-
-        # Speed Control
-        speed_config = {
-            "name": f"{self.device_name} Speed",
-            "unique_id": f"{self.device_id}_speed",
-            "command_topic": self.speed_topic,
-            "state_topic": f"{self.speed_topic}/state",
-            "device": base_device,
-            "icon": "mdi:speedometer",
-            "mode": "box",
-            "min": 50,
-            "max": 2000,
-            "step": 50
-        }
-        self._publish_discovery("number", "speed", speed_config)
-
-        # Pattern Select
-        pattern_config = {
-            "name": f"{self.device_name} Pattern",
-            "unique_id": f"{self.device_id}_pattern",
-            "command_topic": self.pattern_select_topic,
-            "state_topic": f"{self.pattern_select_topic}/state",
-            "options": self.patterns,
-            "device": base_device,
-            "icon": "mdi:draw"
-        }
-        self._publish_discovery("select", "pattern", pattern_config)
-
-        # Playlist Select
-        playlist_config = {
-            "name": f"{self.device_name} Playlist",
-            "unique_id": f"{self.device_id}_playlist",
-            "command_topic": self.playlist_select_topic,
-            "state_topic": f"{self.playlist_select_topic}/state",
-            "options": self.playlists,
-            "device": base_device,
-            "icon": "mdi:playlist-play"
-        }
-        self._publish_discovery("select", "playlist", playlist_config)
-
-        # Playlist Run Mode Select
-        playlist_mode_config = {
-            "name": f"{self.device_name} Playlist Mode",
-            "unique_id": f"{self.device_id}_playlist_mode",
-            "command_topic": f"{self.device_id}/playlist/mode/set",
-            "state_topic": f"{self.device_id}/playlist/mode/state",
-            "options": ["single", "loop"],
-            "device": base_device,
-            "icon": "mdi:repeat",
-            "entity_category": "config"
-        }
-        self._publish_discovery("select", "playlist_mode", playlist_mode_config)
-
-        # Playlist Pause Time Number Input
-        pause_time_config = {
-            "name": f"{self.device_name} Playlist Pause Time",
-            "unique_id": f"{self.device_id}_pause_time",
-            "command_topic": f"{self.device_id}/playlist/pause_time/set",
-            "state_topic": f"{self.device_id}/playlist/pause_time/state",
-            "device": base_device,
-            "icon": "mdi:timer",
-            "entity_category": "config",
-            "mode": "box",
-            "unit_of_measurement": "seconds",
-            "min": 0,
-            "max": 86400,
-        }
-        self._publish_discovery("number", "pause_time", pause_time_config)
-
-        # Clear Pattern Select
-        clear_pattern_config = {
-            "name": f"{self.device_name} Clear Pattern",
-            "unique_id": f"{self.device_id}_clear_pattern",
-            "command_topic": f"{self.device_id}/playlist/clear_pattern/set",
-            "state_topic": f"{self.device_id}/playlist/clear_pattern/state",
-            "options": ["none", "random", "adaptive", "clear_from_in", "clear_from_out", "clear_sideway"],
-            "device": base_device,
-            "icon": "mdi:eraser",
-            "entity_category": "config"
-        }
-        self._publish_discovery("select", "clear_pattern", clear_pattern_config)
-
-        # Shuffle Switch
-        shuffle_config = {
-            "name": f"{self.device_name} Shuffle",
-            "unique_id": f"{self.device_id}_shuffle",
-            "command_topic": f"{self.device_id}/playlist/shuffle/set",
-            "state_topic": f"{self.device_id}/playlist/shuffle/state",
-            "payload_on": "ON",
-            "payload_off": "OFF",
-            "device": base_device,
-            "icon": "mdi:shuffle-variant",
-            "entity_category": "config"
-        }
-        self._publish_discovery("switch", "shuffle", shuffle_config)
-
-        # Completion Percentage Sensor
-        completion_config = {
-            "name": f"{self.device_name} Completion",
-            "unique_id": f"{self.device_id}_completion",
-            "state_topic": self.completion_topic,
-            "device": base_device,
-            "icon": "mdi:progress-clock",
-            "unit_of_measurement": "%",
-            "state_class": "measurement",
-            "entity_category": "diagnostic"
-        }
-        self._publish_discovery("sensor", "completion", completion_config)
-
-        # Time Remaining Sensor
-        time_remaining_config = {
-            "name": f"{self.device_name} Time Remaining",
-            "unique_id": f"{self.device_id}_time_remaining",
-            "state_topic": self.time_remaining_topic,
-            "device": base_device,
-            "icon": "mdi:timer-sand",
-            "unit_of_measurement": "s",
-            "device_class": "duration",
-            "state_class": "measurement",
-            "entity_category": "diagnostic"
-        }
-        self._publish_discovery("sensor", "time_remaining", time_remaining_config)
-
-        # LED Control Entities (only for DW LEDs - WLED has its own MQTT integration)
-        if state.led_provider == "dw_leds":
-            # LED Power Switch
-            led_power_config = {
-                "name": f"{self.device_name} LED Power",
-                "unique_id": f"{self.device_id}_led_power",
-                "command_topic": self.led_power_topic,
-                "state_topic": f"{self.device_id}/led/power/state",
-                "payload_on": "ON",
-                "payload_off": "OFF",
-                "device": base_device,
-                "icon": "mdi:lightbulb",
-                "optimistic": False
-            }
-            self._publish_discovery("switch", "led_power", led_power_config)
-
-            # LED Brightness Control
-            led_brightness_config = {
-                "name": f"{self.device_name} LED Brightness",
-                "unique_id": f"{self.device_id}_led_brightness",
-                "command_topic": self.led_brightness_topic,
-                "state_topic": f"{self.device_id}/led/brightness/state",
-                "device": base_device,
-                "icon": "mdi:brightness-6",
-                "min": 0,
-                "max": 100,
-                "mode": "slider"
-            }
-            self._publish_discovery("number", "led_brightness", led_brightness_config)
-
-            # LED Effect Selector
-            led_effect_options = [
-                "Static", "Blink", "Breathe", "Wipe", "Fade", "Scan", "Dual Scan",
-                "Rainbow Cycle", "Rainbow", "Theater Chase", "Running Lights",
-                "Random Color", "Dynamic", "Twinkle", "Sparkle", "Strobe", "Fire",
-                "Comet", "Chase", "Police", "Lightning", "Fireworks", "Ripple", "Flow",
-                "Colorloop", "Palette Flow", "Gradient", "Multi Strobe", "Waves", "BPM",
-                "Juggle", "Meteor", "Pride", "Pacifica", "Plasma", "Dissolve", "Glitter",
-                "Confetti", "Sinelon", "Candle", "Aurora", "Rain", "Halloween", "Noise",
-                "Funky Plank"
-            ]
-            led_effect_config = {
-                "name": f"{self.device_name} LED Effect",
-                "unique_id": f"{self.device_id}_led_effect",
-                "command_topic": self.led_effect_topic,
-                "state_topic": f"{self.device_id}/led/effect/state",
-                "options": led_effect_options,
-                "device": base_device,
-                "icon": "mdi:palette"
-            }
-            self._publish_discovery("select", "led_effect", led_effect_config)
-
-            # LED Speed Control
-            led_speed_config = {
-                "name": f"{self.device_name} LED Speed",
-                "unique_id": f"{self.device_id}_led_speed",
-                "command_topic": self.led_speed_topic,
-                "state_topic": f"{self.device_id}/led/speed/state",
-                "device": base_device,
-                "icon": "mdi:speedometer",
-                "min": 0,
-                "max": 255,
-                "mode": "slider"
-            }
-            self._publish_discovery("number", "led_speed", led_speed_config)
-
-            # LED Intensity Control
-            led_intensity_config = {
-                "name": f"{self.device_name} LED Intensity",
-                "unique_id": f"{self.device_id}_led_intensity",
-                "command_topic": self.led_intensity_topic,
-                "state_topic": f"{self.device_id}/led/intensity/state",
-                "device": base_device,
-                "icon": "mdi:brightness-7",
-                "min": 0,
-                "max": 255,
-                "mode": "slider"
-            }
-            self._publish_discovery("number", "led_intensity", led_intensity_config)
-
-            # LED RGB Color Control
-            led_color_config = {
-                "name": f"{self.device_name} LED Color",
-                "unique_id": f"{self.device_id}_led_color",
-                "command_topic": self.led_color_topic,
-                "state_topic": f"{self.device_id}/led/color/state",
-                "rgb_command_topic": self.led_color_topic,
-                "rgb_state_topic": f"{self.device_id}/led/color/state",
-                "device": base_device,
-                "icon": "mdi:palette-swatch",
-                "schema": "json",
-                "rgb": True
-            }
-            self._publish_discovery("light", "led_color", led_color_config)
-
-        # Screen Control Entities (only if screen controller is available)
-        if state.screen_controller and state.screen_controller.available:
-            screen_status = state.screen_controller.get_status()
-
-            # Screen Power Switch
-            screen_power_config = {
-                "name": f"{self.device_name} Screen Power",
-                "unique_id": f"{self.device_id}_screen_power",
-                "command_topic": self.screen_power_topic,
-                "state_topic": f"{self.device_id}/screen/power/state",
-                "payload_on": "ON",
-                "payload_off": "OFF",
-                "device": base_device,
-                "icon": "mdi:monitor",
-                "optimistic": False
-            }
-            self._publish_discovery("switch", "screen_power", screen_power_config)
-
-            # Screen Brightness Number
-            screen_brightness_config = {
-                "name": f"{self.device_name} Screen Brightness",
-                "unique_id": f"{self.device_id}_screen_brightness",
-                "command_topic": self.screen_brightness_topic,
-                "state_topic": f"{self.device_id}/screen/brightness/state",
-                "device": base_device,
-                "icon": "mdi:brightness-6",
-                "min": 0,
-                "max": screen_status.get("max_brightness", 255),
-                "mode": "slider"
-            }
-            self._publish_discovery("number", "screen_brightness", screen_brightness_config)
-
-    def _publish_discovery(self, component: str, config_type: str, config: dict):
-        """Helper method to publish HA discovery configs."""
-        if not self.is_enabled:
-            return
-            
-        discovery_topic = f"{self.discovery_prefix}/{component}/{self.device_id}/{config_type}/config"
-        self.client.publish(discovery_topic, json.dumps(config), retain=True)
-
-    def _publish_running_state(self, running_state=None):
-        """Helper to publish running state and button availability."""
-        if running_state is None:
-            if not self.state.current_playing_file:
-                running_state = "idle"
-            elif self.state.pause_requested:
-                running_state = "paused"
-            else:
-                running_state = "running"
-                
-        self.client.publish(self.running_state_topic, running_state, retain=True)
-        
-        # Update button availability based on state
-        self.client.publish(f"{self.device_id}/command/pause/available", 
-                          "true" if running_state == "running" else "false", 
-                          retain=True)
-        self.client.publish(f"{self.device_id}/command/play/available",
-                          "true" if running_state == "paused" else "false",
-                          retain=True)
-        # Skip is available when running and a playlist is active
-        self.client.publish(f"{self.device_id}/command/skip/available",
-                          "true" if running_state in ("running", "paused") and bool(self.state.current_playlist) else "false",
-                          retain=True)
-                          
-    def _publish_pattern_state(self, current_file=None):
-        """Helper to publish pattern state."""
-        if current_file is None:
-            current_file = self.state.current_playing_file
-            
-        if current_file:
-            if current_file.startswith('./patterns/'):
-                current_file = current_file[len('./patterns/'):]
-            else:
-                current_file = current_file.split("/")[-1].split("\\")[-1]
-            self.client.publish(f"{self.pattern_select_topic}/state", current_file, retain=True)
-        else:
-            # Clear the pattern selection
-            self.client.publish(f"{self.pattern_select_topic}/state", "None", retain=True)
-            
-    def _publish_playlist_state(self, playlist_name=None):
-        """Helper to publish playlist state."""
-        if playlist_name is None:
-            playlist_name = self.state.current_playlist_name
-            
-        if playlist_name:
-            self.client.publish(f"{self.playlist_select_topic}/state", playlist_name, retain=True)
-        else:
-            # Clear the playlist selection
-            self.client.publish(f"{self.playlist_select_topic}/state", "None", retain=True)
-            
-    def _publish_serial_state(self):
-        """Helper to publish serial state."""
-        serial_connected = (state.conn.is_connected() if state.conn else False)
-        serial_port = state.port if serial_connected else None
-        serial_status = f"connected to {serial_port}" if serial_connected else "disconnected"
-        self.client.publish(self.serial_state_topic, serial_status, retain=True)
-        
-    def _publish_progress_state(self):
-        """Helper to publish completion percentage and time remaining."""
-        if state.execution_progress:
-            current, total, remaining_time, elapsed_time = state.execution_progress
-            completion_percentage = (current / total * 100) if total > 0 else 0
-            
-            # Publish completion percentage (rounded to 1 decimal place)
-            self.client.publish(self.completion_topic, round(completion_percentage, 1), retain=True)
-            
-            # Publish time remaining (rounded to nearest second, defaulting to 0 if None)
-            time_remaining_seconds = round(remaining_time) if remaining_time is not None else 0
-            self.client.publish(self.time_remaining_topic, max(0, time_remaining_seconds), retain=True)
-        else:
-            # No pattern running, publish zeros
-            self.client.publish(self.completion_topic, 0, retain=True)
-            self.client.publish(self.time_remaining_topic, 0, retain=True)
-
-    def _publish_playlist_settings_state(self):
-        """Helper to publish playlist settings state (mode, pause_time, clear_pattern, shuffle)."""
-        self.client.publish(f"{self.device_id}/playlist/mode/state", state.playlist_mode, retain=True)
-        self.client.publish(f"{self.device_id}/playlist/pause_time/state", state.pause_time, retain=True)
-        self.client.publish(f"{self.device_id}/playlist/clear_pattern/state", state.clear_pattern, retain=True)
-        shuffle_state = "ON" if state.shuffle else "OFF"
-        self.client.publish(f"{self.device_id}/playlist/shuffle/state", shuffle_state, retain=True)
-
-    def _publish_led_state(self):
-        """Helper to publish LED state to MQTT (DW LEDs only - WLED has its own MQTT)."""
-        if not state.led_controller or state.led_provider != "dw_leds":
-            return
-
-        try:
-            status = state.led_controller.check_status()
-            if not status.get("connected", False):
-                return
-
-            # Publish power state (check both "power" for WLED compatibility and "power_on" for DW LEDs)
-            is_powered = status.get("power_on", status.get("power", False))
-            power_state = "ON" if is_powered else "OFF"
-            self.client.publish(f"{self.device_id}/led/power/state", power_state, retain=True)
-
-            # Publish brightness (convert from 0-1 to 0-100)
-            if "brightness" in status:
-                brightness = int(status["brightness"] * 100)
-                self.client.publish(f"{self.device_id}/led/brightness/state", brightness, retain=True)
-
-            # Publish effect
-            if "effect_id" in status:
-                effect_map = {
-                    0: "Static", 1: "Blink", 2: "Breathe", 3: "Wipe", 4: "Fade",
-                    5: "Scan", 6: "Dual Scan", 7: "Rainbow Cycle", 8: "Rainbow",
-                    9: "Theater Chase", 10: "Running Lights", 11: "Random Color",
-                    12: "Dynamic", 13: "Twinkle", 14: "Sparkle", 15: "Strobe",
-                    16: "Fire", 17: "Comet", 18: "Chase", 19: "Police", 20: "Lightning",
-                    21: "Fireworks", 22: "Ripple", 23: "Flow", 24: "Colorloop",
-                    25: "Palette Flow", 26: "Gradient", 27: "Multi Strobe", 28: "Waves",
-                    29: "BPM", 30: "Juggle", 31: "Meteor", 32: "Pride", 33: "Pacifica",
-                    34: "Plasma", 35: "Dissolve", 36: "Glitter", 37: "Confetti",
-                    38: "Sinelon", 39: "Candle", 40: "Aurora", 41: "Rain",
-                    42: "Halloween", 43: "Noise", 44: "Funky Plank"
-                }
-                effect_name = effect_map.get(status["effect_id"], "Static")
-                self.client.publish(f"{self.device_id}/led/effect/state", effect_name, retain=True)
-
-            # Publish speed
-            if "speed" in status:
-                self.client.publish(f"{self.device_id}/led/speed/state", status["speed"], retain=True)
-
-            # Publish intensity
-            if "intensity" in status:
-                self.client.publish(f"{self.device_id}/led/intensity/state", status["intensity"], retain=True)
-
-            # Publish color (RGB)
-            if "colors" in status and len(status["colors"]) > 0:
-                # colors is array of hex strings like ["#ff0000", "#00ff00", "#0000ff"]
-                # Convert first color to RGB dict
-                color_hex = status["colors"][0]
-                if color_hex and color_hex.startswith('#') and len(color_hex) == 7:
-                    r = int(color_hex[1:3], 16)
-                    g = int(color_hex[3:5], 16)
-                    b = int(color_hex[5:7], 16)
-                    self.client.publish(f"{self.device_id}/led/color/state",
-                                      json.dumps({"r": r, "g": g, "b": b}), retain=True)
-
-        except Exception as e:
-            logger.error(f"Error publishing LED state: {e}")
-
-    def _publish_screen_state(self):
-        """Helper to publish screen (LCD backlight) state to MQTT."""
-        if not state.screen_controller or not state.screen_controller.available:
-            return
-
-        try:
-            status = state.screen_controller.get_status()
-            power_state = "ON" if status["power_on"] else "OFF"
-            self.client.publish(f"{self.device_id}/screen/power/state", power_state, retain=True)
-            self.client.publish(f"{self.device_id}/screen/brightness/state", status["brightness"], retain=True)
-        except Exception as e:
-            logger.error(f"Error publishing screen state: {e}")
-
-    def update_state(self, current_file=None, is_running=None, playlist=None, playlist_name=None):
-        """Update state in Home Assistant. Only publishes the attributes that are explicitly passed."""
-        if not self.is_enabled:
-            return
-
-        # Update pattern state if current_file is provided
-        if current_file is not None:
-            self._publish_pattern_state(current_file)
-        
-        # Update running state and button availability if is_running is provided
-        if is_running is not None:
-            running_state = "running" if is_running else "paused" if self.state.current_playing_file else "idle"
-            self._publish_running_state(running_state)
-        
-        # Update playlist state if playlist info is provided
-        if playlist_name is not None:
-            self._publish_playlist_state(playlist_name)
-
-    def on_connect(self, client, userdata, flags, rc):
-        """Callback when connected to MQTT broker."""
-        if rc == 0:
-            self._connected = True
-            logger.info(f"MQTT Connection Accepted. client_id={self.client_id}")
-            # Subscribe to command topics
-            client.subscribe([
-                (self.command_topic, 0),
-                (self.pattern_select_topic, 0),
-                (self.playlist_select_topic, 0),
-                (self.speed_topic, 0),
-                (f"{self.device_id}/command/stop", 0),
-                (f"{self.device_id}/command/pause", 0),
-                (f"{self.device_id}/command/play", 0),
-                (f"{self.device_id}/command/skip", 0),
-                (f"{self.device_id}/playlist/mode/set", 0),
-                (f"{self.device_id}/playlist/pause_time/set", 0),
-                (f"{self.device_id}/playlist/clear_pattern/set", 0),
-                (f"{self.device_id}/playlist/shuffle/set", 0),
-                (self.led_power_topic, 0),
-                (self.led_brightness_topic, 0),
-                (self.led_effect_topic, 0),
-                (self.led_speed_topic, 0),
-                (self.led_intensity_topic, 0),
-                (self.led_color_topic, 0),
-                (self.screen_power_topic, 0),
-                (self.screen_brightness_topic, 0),
-            ])
-            # Publish discovery configurations
-            self.setup_ha_discovery()
-        else:
-            self._connected = False
-            error_messages = {
-                1: "Protocol level not supported",
-                2: "The client-identifier is not allowed by the server",
-                3: "The MQTT service is not available",
-                4: "The data in the username or password is malformed",
-                5: "The client is not authorized to connect"
-            }
-            error_msg = error_messages.get(rc, f"Unknown error code: {rc}")
-            logger.error(f"MQTT Connection Refused. {error_msg}")
-
-    def on_disconnect(self, client, userdata, rc):
-        """Callback when disconnected from MQTT broker."""
-        self._connected = False
-        if rc == 0:
-            logger.info("MQTT disconnected cleanly")
-        else:
-            # paho-mqtt MQTT_ERR_* codes — NOT broker CONNACK codes
-            err_names = {
-                1: "NOMEM", 2: "PROTOCOL", 3: "INVAL", 4: "NO_CONN",
-                5: "CONN_REFUSED", 6: "NOT_FOUND", 7: "CONN_LOST",
-                8: "TLS", 9: "PAYLOAD_SIZE", 10: "NOT_SUPPORTED",
-                11: "AUTH", 12: "ACL_DENIED", 13: "UNKNOWN", 14: "ERRNO",
-            }
-            err_name = err_names.get(rc, f"rc={rc}")
-            logger.warning(
-                f"MQTT disconnected unexpectedly: {err_name} (rc={rc}) "
-                f"client_id={self.client_id}"
-            )
-
-    def on_message(self, client, userdata, msg):
-        """Callback when message is received."""
-        try:
-            if msg.topic == self.pattern_select_topic:
-                from modules.core.pattern_manager import THETA_RHO_DIR
-                # Handle pattern selection
-                pattern_name = msg.payload.decode()
-                if pattern_name in self.patterns:
-                    # Schedule the coroutine to run in the main event loop
-                    asyncio.run_coroutine_threadsafe(
-                        self.callback_registry['run_pattern'](file_path=f"{THETA_RHO_DIR}/{pattern_name}"),
-                        self.main_loop
-                    ).add_done_callback(
-                        lambda _: self._publish_pattern_state(None)  # Clear pattern after execution
-                    )
-                    self.client.publish(f"{self.pattern_select_topic}/state", pattern_name, retain=True)
-            elif msg.topic == self.playlist_select_topic:
-                # Handle playlist selection
-                playlist_name = msg.payload.decode()
-                if playlist_name in self.playlists:
-                    # Schedule the coroutine to run in the main event loop
-                    asyncio.run_coroutine_threadsafe(
-                        self.callback_registry['run_playlist'](
-                            playlist_name=playlist_name,
-                            run_mode=self.state.playlist_mode,
-                            pause_time=self.state.pause_time,
-                            clear_pattern=self.state.clear_pattern,
-                            shuffle=self.state.shuffle
-                        ),
-                        self.main_loop
-                    ).add_done_callback(
-                        lambda _: self._publish_playlist_state(None)  # Clear playlist after execution
-                    )
-                    self.client.publish(f"{self.playlist_select_topic}/state", playlist_name, retain=True)
-            elif msg.topic == self.speed_topic:
-                speed = int(msg.payload.decode())
-                self.callback_registry['set_speed'](speed)
-            elif msg.topic == f"{self.device_id}/command/stop":
-                # Handle stop command
-                callback = self.callback_registry['stop']
-                if asyncio.iscoroutinefunction(callback):
-                    asyncio.run_coroutine_threadsafe(callback(), self.main_loop)
-                else:
-                    callback()
-                # Clear both pattern and playlist selections
-                self._publish_pattern_state(None)
-                self._publish_playlist_state(None)
-            elif msg.topic == f"{self.device_id}/command/pause":
-                # Handle pause command - only if in running state
-                if bool(self.state.current_playing_file) and not self.state.pause_requested:
-                    # Check if callback is async or sync
-                    callback = self.callback_registry['pause']
-                    if asyncio.iscoroutinefunction(callback):
-                        asyncio.run_coroutine_threadsafe(callback(), self.main_loop)
-                    else:
-                        callback()
-            elif msg.topic == f"{self.device_id}/command/play":
-                # Handle play command - only if in paused state
-                if bool(self.state.current_playing_file) and self.state.pause_requested:
-                    # Check if callback is async or sync
-                    callback = self.callback_registry['resume']
-                    if asyncio.iscoroutinefunction(callback):
-                        asyncio.run_coroutine_threadsafe(callback(), self.main_loop)
-                    else:
-                        callback()
-            elif msg.topic == f"{self.device_id}/command/skip":
-                # Handle skip command - only if a playlist is running
-                if self.state.current_playlist:
-                    callback = self.callback_registry['skip']
-                    if asyncio.iscoroutinefunction(callback):
-                        asyncio.run_coroutine_threadsafe(callback(), self.main_loop)
-                    else:
-                        callback()
-            elif msg.topic == f"{self.device_id}/playlist/mode/set":
-                mode = msg.payload.decode()
-                if mode in ["single", "loop"]:
-                    state.playlist_mode = mode
-                    self.client.publish(f"{self.device_id}/playlist/mode/state", mode, retain=True)
-            elif msg.topic == f"{self.device_id}/playlist/pause_time/set":
-                pause_time = float(msg.payload.decode())
-                if 0 <= pause_time <= 60:
-                    state.pause_time = pause_time
-                    self.client.publish(f"{self.device_id}/playlist/pause_time/state", pause_time, retain=True)
-            elif msg.topic == f"{self.device_id}/playlist/clear_pattern/set":
-                clear_pattern = msg.payload.decode()
-                if clear_pattern in ["none", "random", "adaptive", "clear_from_in", "clear_from_out", "clear_sideway"]:
-                    state.clear_pattern = clear_pattern
-                    self.client.publish(f"{self.device_id}/playlist/clear_pattern/state", clear_pattern, retain=True)
-            elif msg.topic == f"{self.device_id}/playlist/shuffle/set":
-                payload = msg.payload.decode()
-                shuffle_value = payload == "ON"
-                state.shuffle = shuffle_value
-                self.client.publish(f"{self.device_id}/playlist/shuffle/state", payload, retain=True)
-            elif msg.topic == self.led_power_topic:
-                # Handle LED power command (DW LEDs only)
-                payload = msg.payload.decode()
-                if state.led_controller and state.led_provider == "dw_leds":
-                    power_state = 1 if payload == "ON" else 0
-                    state.led_controller.set_power(power_state)
-                    # Reset idle timeout when LEDs are manually powered on via MQTT (only if idle timeout is enabled)
-                    if payload == "ON" and state.dw_led_idle_timeout_enabled:
-                        state.dw_led_last_activity_time = time.time()
-                        logger.debug("LED activity time reset due to MQTT power on")
-                    self.client.publish(f"{self.device_id}/led/power/state", payload, retain=True)
-            elif msg.topic == self.led_brightness_topic:
-                # Handle LED brightness command (DW LEDs only)
-                brightness = int(msg.payload.decode())
-                if 0 <= brightness <= 100 and state.led_controller and state.led_provider == "dw_leds":
-                    controller = state.led_controller.get_controller()
-                    if controller and hasattr(controller, 'set_brightness'):
-                        # DW LED controller expects 0-100, converts internally to 0.0-1.0
-                        controller.set_brightness(brightness)
-                        self.client.publish(f"{self.device_id}/led/brightness/state", brightness, retain=True)
-            elif msg.topic == self.led_effect_topic:
-                # Handle LED effect command (DW LEDs only)
-                effect_name = msg.payload.decode()
-                if state.led_controller and state.led_provider == "dw_leds":
-                    # Map effect name to ID
-                    effect_map = {
-                        "Static": 0, "Blink": 1, "Breathe": 2, "Wipe": 3, "Fade": 4,
-                        "Scan": 5, "Dual Scan": 6, "Rainbow Cycle": 7, "Rainbow": 8,
-                        "Theater Chase": 9, "Running Lights": 10, "Random Color": 11,
-                        "Dynamic": 12, "Twinkle": 13, "Sparkle": 14, "Strobe": 15,
-                        "Fire": 16, "Comet": 17, "Chase": 18, "Police": 19, "Lightning": 20,
-                        "Fireworks": 21, "Ripple": 22, "Flow": 23, "Colorloop": 24,
-                        "Palette Flow": 25, "Gradient": 26, "Multi Strobe": 27, "Waves": 28,
-                        "BPM": 29, "Juggle": 30, "Meteor": 31, "Pride": 32, "Pacifica": 33,
-                        "Plasma": 34, "Dissolve": 35, "Glitter": 36, "Confetti": 37,
-                        "Sinelon": 38, "Candle": 39, "Aurora": 40, "Rain": 41,
-                        "Halloween": 42, "Noise": 43, "Funky Plank": 44
-                    }
-                    effect_id = effect_map.get(effect_name)
-                    if effect_id is not None:
-                        controller = state.led_controller.get_controller()
-                        if controller and hasattr(controller, 'set_effect'):
-                            controller.set_effect(effect_id)
-                            self.client.publish(f"{self.device_id}/led/effect/state", effect_name, retain=True)
-            elif msg.topic == self.led_speed_topic:
-                # Handle LED speed command (DW LEDs only)
-                speed = int(msg.payload.decode())
-                if 0 <= speed <= 255 and state.led_controller and state.led_provider == "dw_leds":
-                    controller = state.led_controller.get_controller()
-                    if controller and hasattr(controller, 'set_speed'):
-                        controller.set_speed(speed)
-                        self.client.publish(f"{self.device_id}/led/speed/state", speed, retain=True)
-            elif msg.topic == self.led_intensity_topic:
-                # Handle LED intensity command (DW LEDs only)
-                intensity = int(msg.payload.decode())
-                if 0 <= intensity <= 255 and state.led_controller and state.led_provider == "dw_leds":
-                    controller = state.led_controller.get_controller()
-                    if controller and hasattr(controller, 'set_intensity'):
-                        controller.set_intensity(intensity)
-                        self.client.publish(f"{self.device_id}/led/intensity/state", intensity, retain=True)
-            elif msg.topic == self.led_color_topic:
-                # Handle LED color command (RGB) (DW LEDs only)
-                try:
-                    color_data = json.loads(msg.payload.decode())
-                    if state.led_controller and state.led_provider == "dw_leds" and 'r' in color_data and 'g' in color_data and 'b' in color_data:
-                        controller = state.led_controller.get_controller()
-                        if controller and hasattr(controller, 'set_color'):
-                            r, g, b = color_data['r'], color_data['g'], color_data['b']
-                            controller.set_color(r, g, b)
-                            self.client.publish(f"{self.device_id}/led/color/state",
-                                              json.dumps({"r": r, "g": g, "b": b}), retain=True)
-                except json.JSONDecodeError:
-                    logger.error(f"Invalid JSON for color command: {msg.payload}")
-            elif msg.topic == self.screen_power_topic:
-                # Handle screen power command
-                payload = msg.payload.decode()
-                if state.screen_controller and state.screen_controller.available:
-                    state.screen_controller.set_power(payload == "ON")
-                    self._publish_screen_state()
-            elif msg.topic == self.screen_brightness_topic:
-                # Handle screen brightness command
-                brightness = int(msg.payload.decode())
-                if state.screen_controller and state.screen_controller.available:
-                    state.screen_controller.set_brightness(brightness)
-                    self._publish_screen_state()
-            else:
-                # Handle other commands
-                payload = json.loads(msg.payload.decode())
-                command = payload.get('command')
-                params = payload.get('params', {})
-
-                if command in self.callback_registry:
-                    self.callback_registry[command](**params)
-                else:
-                    logger.error(f"Unknown command received: {command}")
-
-        except json.JSONDecodeError:
-            logger.error(f"Invalid JSON payload received: {msg.payload}")
-        except Exception as e:
-            logger.error(f"Error processing MQTT message: {e}")
-
-    def publish_status(self):
-        """Publish status updates periodically."""
-        while self.running:
-            try:
-                # Update all states
-                self._publish_running_state()
-                self._publish_pattern_state()
-                self._publish_playlist_state()
-                self._publish_serial_state()
-                self._publish_progress_state()
-                
-                # Update speed state
-                self.client.publish(f"{self.speed_topic}/state", self.state.speed, retain=True)
-
-                # Update LED state
-                self._publish_led_state()
-
-                # Update screen state
-                self._publish_screen_state()
-
-                # Publish keepalive status
-                status = {
-                    "timestamp": time.time(),
-                    "client_id": self.client_id
-                }
-                self.client.publish(self.status_topic, json.dumps(status))
-                
-                # Wait for next interval
-                time.sleep(self.status_interval)
-            except Exception as e:
-                logger.error(f"Error publishing status: {e}")
-                time.sleep(5)  # Wait before retry
-
-    def start(self) -> None:
-        """Start the MQTT handler."""
-        if not self.is_enabled:
-            return
-        
-        try:
-            self.client.connect(self.broker, self.port)
-            self.client.loop_start()
-            
-            # Start status publishing thread
-            self.running = True
-            self.status_thread = threading.Thread(target=self.publish_status, daemon=True)
-            self.status_thread.start()
-            
-            # Get initial pattern and playlist lists
-            self.patterns = list_theta_rho_files()
-            self.playlists = list_all_playlists()
-
-            # Wait a bit for MQTT connection to establish
-            time.sleep(1)
-            
-            # Publish initial states
-            self._publish_running_state()
-            self._publish_pattern_state()
-            self._publish_playlist_state()
-            self._publish_serial_state()
-            self._publish_progress_state()
-            self._publish_playlist_settings_state()
-            self._publish_led_state()
-            self._publish_screen_state()
-
-            # Setup Home Assistant discovery
-            self.setup_ha_discovery()
-            
-            logger.info("MQTT Handler started successfully")
-        except Exception as e:
-            logger.error(f"Failed to start MQTT Handler: {e}")
-
-    def stop(self) -> None:
-        """Stop the MQTT handler."""
-        if not self.is_enabled:
-            return
-
-        # First stop the running flag to prevent new iterations
-        self.running = False
-        
-        # Clean up status thread
-        local_status_thread = self.status_thread  # Keep a local reference
-        if local_status_thread and local_status_thread.is_alive():
-            try:
-                local_status_thread.join(timeout=5)
-                if local_status_thread.is_alive():
-                    logger.warning("MQTT status thread did not terminate cleanly")
-            except Exception as e:
-                logger.error(f"Error joining status thread: {e}")
-        self.status_thread = None
-            
-        # Clean up MQTT client
-        try:
-            if hasattr(self, 'client'):
-                self.client.loop_stop()
-                self.client.disconnect()
-        except Exception as e:
-            logger.error(f"Error disconnecting MQTT client: {e}")
-        
-        # Clean up main loop reference
-        self.main_loop = None
-        
-        logger.info("MQTT handler stopped")
-
-    @property
-    def is_enabled(self) -> bool:
-        """Return whether MQTT functionality is enabled.
-
-        MQTT is enabled if:
-        1. A broker address is configured (either via state or env var), AND
-        2. Either state.mqtt_enabled is True, OR no UI config exists (env-only mode)
-        """
-        # If no broker configured, MQTT is disabled
-        if not self.broker:
-            return False
-
-        # If state has mqtt_enabled explicitly set (UI was used), respect that setting
-        # If mqtt_broker is set in state, user configured via UI - use mqtt_enabled
-        if state.mqtt_broker:
-            return state.mqtt_enabled
-
-        # Otherwise, broker came from env vars - enable if broker exists
-        return True
-
-    @property
-    def is_connected(self) -> bool:
-        """Return whether MQTT client is currently connected to the broker."""
+"""Real MQTT handler implementation."""
+import os
+import threading
+import time
+import json
+import uuid
+from typing import Dict, Callable
+import paho.mqtt.client as mqtt
+import logging
+import asyncio
+
+from .base import BaseMQTTHandler
+from modules.core.state import state
+from modules.core.pattern_manager import list_theta_rho_files
+from modules.core.playlist_manager import list_all_playlists
+
+logger = logging.getLogger(__name__)
+
+class MQTTHandler(BaseMQTTHandler):
+    """Real implementation of MQTT handler."""
+
+    def __init__(self, callback_registry: Dict[str, Callable]):
+        # MQTT Configuration - prioritize state config over environment variables
+        # This allows UI configuration to override .env settings
+        self.broker = state.mqtt_broker if state.mqtt_broker else os.getenv('MQTT_BROKER')
+        self.port = state.mqtt_port if state.mqtt_port else int(os.getenv('MQTT_PORT', '1883'))
+        self.username = state.mqtt_username if state.mqtt_username else os.getenv('MQTT_USERNAME')
+        self.password = state.mqtt_password if state.mqtt_password else os.getenv('MQTT_PASSWORD')
+        self.status_topic = os.getenv('MQTT_STATUS_TOPIC', 'dune_weaver/status')
+        self.command_topic = os.getenv('MQTT_COMMAND_TOPIC', 'dune_weaver/command')
+        self.status_interval = int(os.getenv('MQTT_STATUS_INTERVAL', '30'))
+
+        # Store callback registry
+        self.callback_registry = callback_registry
+
+        # Threading control
+        self.running = False
+        self.status_thread = None
+
+        # Home Assistant MQTT Discovery settings - prioritize state config
+        self.discovery_prefix = state.mqtt_discovery_prefix if state.mqtt_discovery_prefix else os.getenv('MQTT_DISCOVERY_PREFIX', 'homeassistant')
+        self.device_name = state.mqtt_device_name if state.mqtt_device_name else os.getenv('HA_DEVICE_NAME', 'Dune Weaver')
+        self.device_id = state.mqtt_device_id if state.mqtt_device_id else os.getenv('HA_DEVICE_ID', 'dune_weaver')
+
+        # MQTT broker-level client identity. If the user hasn't set one explicitly,
+        # generate a random suffix so two instances can never collide on the same
+        # client_id and kick each other off the broker in a reconnect loop. The
+        # device_id prefix keeps the random ID greppable in broker logs.
+        self.client_id = (
+            state.mqtt_client_id
+            or os.getenv('MQTT_CLIENT_ID')
+            or f"{self.device_id}-{uuid.uuid4().hex[:8]}"
+        )
+
+        # Additional topics for state
+        self.running_state_topic = f"{self.device_id}/state/running"
+        self.serial_state_topic = f"{self.device_id}/state/serial"
+        self.pattern_select_topic = f"{self.device_id}/pattern/set"
+        self.playlist_select_topic = f"{self.device_id}/playlist/set"
+        self.speed_topic = f"{self.device_id}/speed/set"
+        self.completion_topic = f"{self.device_id}/state/completion"
+        self.time_remaining_topic = f"{self.device_id}/state/time_remaining"
+
+        # Screen control topics
+        self.screen_power_topic = f"{self.device_id}/screen/power/set"
+        self.screen_brightness_topic = f"{self.device_id}/screen/brightness/set"
+
+        # LED control topics
+        self.led_power_topic = f"{self.device_id}/led/power/set"
+        self.led_brightness_topic = f"{self.device_id}/led/brightness/set"
+        self.led_effect_topic = f"{self.device_id}/led/effect/set"
+        self.led_speed_topic = f"{self.device_id}/led/speed/set"
+        self.led_intensity_topic = f"{self.device_id}/led/intensity/set"
+        self.led_color_topic = f"{self.device_id}/led/color/set"
+
+        # Store current state
+        self.current_file = ""
+        self.is_running_state = False
+        self.serial_state = ""
+        self.patterns = []
+        self.playlists = []
+
+        # Track connection state
+        self._connected = False
+
+        # Initialize MQTT client if broker is configured
+        if self.broker:
+            self.client = mqtt.Client(client_id=self.client_id)
+            self.client.on_connect = self.on_connect
+            self.client.on_disconnect = self.on_disconnect
+            self.client.on_message = self.on_message
+
+            if self.username and self.password:
+                self.client.username_pw_set(self.username, self.password)
+
+        self.state = state
+        self.state.mqtt_handler = self  # Set reference to self in state, needed so that state setters can update the state
+
+        # Store the main event loop during initialization
+        self.main_loop = asyncio.get_event_loop()
+
+    def setup_ha_discovery(self):
+        """Publish Home Assistant MQTT discovery configurations."""
+        if not self.is_enabled:
+            return
+
+        base_device = {
+            "identifiers": [self.device_id],
+            "name": self.device_name,
+            "model": "Dune Weaver",
+            "manufacturer": "DIY"
+        }
+        
+        # Serial State Sensor
+        serial_config = {
+            "name": f"{self.device_name} Serial State",
+            "unique_id": f"{self.device_id}_serial_state",
+            "state_topic": self.serial_state_topic,
+            "device": base_device,
+            "icon": "mdi:serial-port",
+            "entity_category": "diagnostic"
+        }
+        self._publish_discovery("sensor", "serial_state", serial_config)
+
+        # Running State Sensor
+        running_config = {
+            "name": f"{self.device_name} Running State",
+            "unique_id": f"{self.device_id}_running_state",
+            "state_topic": self.running_state_topic,
+            "device": base_device,
+            "icon": "mdi:machine",
+            "entity_category": "diagnostic"
+        }
+        self._publish_discovery("sensor", "running_state", running_config)
+
+        # Stop Button
+        stop_config = {
+            "name": "Stop pattern execution",
+            "unique_id": f"{self.device_id}_stop",
+            "command_topic": f"{self.device_id}/command/stop",
+            "device": base_device,
+            "icon": "mdi:stop",
+            "entity_category": "config"
+        }
+        self._publish_discovery("button", "stop", stop_config)
+
+        # Pause Button
+        pause_config = {
+            "name": "Pause pattern execution",
+            "unique_id": f"{self.device_id}_pause",
+            "command_topic": f"{self.device_id}/command/pause",
+            "state_topic": f"{self.device_id}/command/pause/state",
+            "device": base_device,
+            "icon": "mdi:pause",
+            "entity_category": "config",
+            "enabled_by_default": True,
+            "availability": {
+                "topic": f"{self.device_id}/command/pause/available",
+                "payload_available": "true",
+                "payload_not_available": "false"
+            }
+        }
+        self._publish_discovery("button", "pause", pause_config)
+
+        # Play Button
+        play_config = {
+            "name": "Resume pattern execution",
+            "unique_id": f"{self.device_id}_play",
+            "command_topic": f"{self.device_id}/command/play",
+            "state_topic": f"{self.device_id}/command/play/state",
+            "device": base_device,
+            "icon": "mdi:play",
+            "entity_category": "config",
+            "enabled_by_default": True,
+            "availability": {
+                "topic": f"{self.device_id}/command/play/available",
+                "payload_available": "true",
+                "payload_not_available": "false"
+            }
+        }
+        self._publish_discovery("button", "play", play_config)
+
+        # Skip Button
+        skip_config = {
+            "name": "Skip to next pattern",
+            "unique_id": f"{self.device_id}_skip",
+            "command_topic": f"{self.device_id}/command/skip",
+            "device": base_device,
+            "icon": "mdi:skip-next",
+            "entity_category": "config",
+            "enabled_by_default": True,
+            "availability": {
+                "topic": f"{self.device_id}/command/skip/available",
+                "payload_available": "true",
+                "payload_not_available": "false"
+            }
+        }
+        self._publish_discovery("button", "skip", skip_config)
+
+        # Speed Control
+        speed_config = {
+            "name": f"{self.device_name} Speed",
+            "unique_id": f"{self.device_id}_speed",
+            "command_topic": self.speed_topic,
+            "state_topic": f"{self.speed_topic}/state",
+            "device": base_device,
+            "icon": "mdi:speedometer",
+            "mode": "box",
+            "min": 50,
+            "max": 2000,
+            "step": 50
+        }
+        self._publish_discovery("number", "speed", speed_config)
+
+        # Pattern Select
+        pattern_config = {
+            "name": f"{self.device_name} Pattern",
+            "unique_id": f"{self.device_id}_pattern",
+            "command_topic": self.pattern_select_topic,
+            "state_topic": f"{self.pattern_select_topic}/state",
+            "options": self.patterns,
+            "device": base_device,
+            "icon": "mdi:draw"
+        }
+        self._publish_discovery("select", "pattern", pattern_config)
+
+        # Playlist Select
+        playlist_config = {
+            "name": f"{self.device_name} Playlist",
+            "unique_id": f"{self.device_id}_playlist",
+            "command_topic": self.playlist_select_topic,
+            "state_topic": f"{self.playlist_select_topic}/state",
+            "options": self.playlists,
+            "device": base_device,
+            "icon": "mdi:playlist-play"
+        }
+        self._publish_discovery("select", "playlist", playlist_config)
+
+        # Playlist Run Mode Select
+        playlist_mode_config = {
+            "name": f"{self.device_name} Playlist Mode",
+            "unique_id": f"{self.device_id}_playlist_mode",
+            "command_topic": f"{self.device_id}/playlist/mode/set",
+            "state_topic": f"{self.device_id}/playlist/mode/state",
+            "options": ["single", "loop"],
+            "device": base_device,
+            "icon": "mdi:repeat",
+            "entity_category": "config"
+        }
+        self._publish_discovery("select", "playlist_mode", playlist_mode_config)
+
+        # Playlist Pause Time Number Input
+        pause_time_config = {
+            "name": f"{self.device_name} Playlist Pause Time",
+            "unique_id": f"{self.device_id}_pause_time",
+            "command_topic": f"{self.device_id}/playlist/pause_time/set",
+            "state_topic": f"{self.device_id}/playlist/pause_time/state",
+            "device": base_device,
+            "icon": "mdi:timer",
+            "entity_category": "config",
+            "mode": "box",
+            "unit_of_measurement": "seconds",
+            "min": 0,
+            "max": 86400,
+        }
+        self._publish_discovery("number", "pause_time", pause_time_config)
+
+        # Clear Pattern Select
+        clear_pattern_config = {
+            "name": f"{self.device_name} Clear Pattern",
+            "unique_id": f"{self.device_id}_clear_pattern",
+            "command_topic": f"{self.device_id}/playlist/clear_pattern/set",
+            "state_topic": f"{self.device_id}/playlist/clear_pattern/state",
+            "options": ["none", "random", "adaptive", "clear_from_in", "clear_from_out", "clear_sideway"],
+            "device": base_device,
+            "icon": "mdi:eraser",
+            "entity_category": "config"
+        }
+        self._publish_discovery("select", "clear_pattern", clear_pattern_config)
+
+        # Shuffle Switch
+        shuffle_config = {
+            "name": f"{self.device_name} Shuffle",
+            "unique_id": f"{self.device_id}_shuffle",
+            "command_topic": f"{self.device_id}/playlist/shuffle/set",
+            "state_topic": f"{self.device_id}/playlist/shuffle/state",
+            "payload_on": "ON",
+            "payload_off": "OFF",
+            "device": base_device,
+            "icon": "mdi:shuffle-variant",
+            "entity_category": "config"
+        }
+        self._publish_discovery("switch", "shuffle", shuffle_config)
+
+        # Completion Percentage Sensor
+        completion_config = {
+            "name": f"{self.device_name} Completion",
+            "unique_id": f"{self.device_id}_completion",
+            "state_topic": self.completion_topic,
+            "device": base_device,
+            "icon": "mdi:progress-clock",
+            "unit_of_measurement": "%",
+            "state_class": "measurement",
+            "entity_category": "diagnostic"
+        }
+        self._publish_discovery("sensor", "completion", completion_config)
+
+        # Time Remaining Sensor
+        time_remaining_config = {
+            "name": f"{self.device_name} Time Remaining",
+            "unique_id": f"{self.device_id}_time_remaining",
+            "state_topic": self.time_remaining_topic,
+            "device": base_device,
+            "icon": "mdi:timer-sand",
+            "unit_of_measurement": "s",
+            "device_class": "duration",
+            "state_class": "measurement",
+            "entity_category": "diagnostic"
+        }
+        self._publish_discovery("sensor", "time_remaining", time_remaining_config)
+
+        # LED Control Entities (only for DW LEDs - WLED has its own MQTT integration)
+        if state.led_provider == "board":
+            # LED Power Switch
+            led_power_config = {
+                "name": f"{self.device_name} LED Power",
+                "unique_id": f"{self.device_id}_led_power",
+                "command_topic": self.led_power_topic,
+                "state_topic": f"{self.device_id}/led/power/state",
+                "payload_on": "ON",
+                "payload_off": "OFF",
+                "device": base_device,
+                "icon": "mdi:lightbulb",
+                "optimistic": False
+            }
+            self._publish_discovery("switch", "led_power", led_power_config)
+
+            # LED Brightness Control
+            led_brightness_config = {
+                "name": f"{self.device_name} LED Brightness",
+                "unique_id": f"{self.device_id}_led_brightness",
+                "command_topic": self.led_brightness_topic,
+                "state_topic": f"{self.device_id}/led/brightness/state",
+                "device": base_device,
+                "icon": "mdi:brightness-6",
+                "min": 0,
+                "max": 100,
+                "mode": "slider"
+            }
+            self._publish_discovery("number", "led_brightness", led_brightness_config)
+
+            # LED Effect Selector
+            led_effect_options = [
+                "Static", "Blink", "Breathe", "Wipe", "Fade", "Scan", "Dual Scan",
+                "Rainbow Cycle", "Rainbow", "Theater Chase", "Running Lights",
+                "Random Color", "Dynamic", "Twinkle", "Sparkle", "Strobe", "Fire",
+                "Comet", "Chase", "Police", "Lightning", "Fireworks", "Ripple", "Flow",
+                "Colorloop", "Palette Flow", "Gradient", "Multi Strobe", "Waves", "BPM",
+                "Juggle", "Meteor", "Pride", "Pacifica", "Plasma", "Dissolve", "Glitter",
+                "Confetti", "Sinelon", "Candle", "Aurora", "Rain", "Halloween", "Noise",
+                "Funky Plank"
+            ]
+            led_effect_config = {
+                "name": f"{self.device_name} LED Effect",
+                "unique_id": f"{self.device_id}_led_effect",
+                "command_topic": self.led_effect_topic,
+                "state_topic": f"{self.device_id}/led/effect/state",
+                "options": led_effect_options,
+                "device": base_device,
+                "icon": "mdi:palette"
+            }
+            self._publish_discovery("select", "led_effect", led_effect_config)
+
+            # LED Speed Control
+            led_speed_config = {
+                "name": f"{self.device_name} LED Speed",
+                "unique_id": f"{self.device_id}_led_speed",
+                "command_topic": self.led_speed_topic,
+                "state_topic": f"{self.device_id}/led/speed/state",
+                "device": base_device,
+                "icon": "mdi:speedometer",
+                "min": 0,
+                "max": 255,
+                "mode": "slider"
+            }
+            self._publish_discovery("number", "led_speed", led_speed_config)
+
+            # LED Intensity Control
+            led_intensity_config = {
+                "name": f"{self.device_name} LED Intensity",
+                "unique_id": f"{self.device_id}_led_intensity",
+                "command_topic": self.led_intensity_topic,
+                "state_topic": f"{self.device_id}/led/intensity/state",
+                "device": base_device,
+                "icon": "mdi:brightness-7",
+                "min": 0,
+                "max": 255,
+                "mode": "slider"
+            }
+            self._publish_discovery("number", "led_intensity", led_intensity_config)
+
+            # LED RGB Color Control
+            led_color_config = {
+                "name": f"{self.device_name} LED Color",
+                "unique_id": f"{self.device_id}_led_color",
+                "command_topic": self.led_color_topic,
+                "state_topic": f"{self.device_id}/led/color/state",
+                "rgb_command_topic": self.led_color_topic,
+                "rgb_state_topic": f"{self.device_id}/led/color/state",
+                "device": base_device,
+                "icon": "mdi:palette-swatch",
+                "schema": "json",
+                "rgb": True
+            }
+            self._publish_discovery("light", "led_color", led_color_config)
+
+        # Screen Control Entities (only if screen controller is available)
+        if state.screen_controller and state.screen_controller.available:
+            screen_status = state.screen_controller.get_status()
+
+            # Screen Power Switch
+            screen_power_config = {
+                "name": f"{self.device_name} Screen Power",
+                "unique_id": f"{self.device_id}_screen_power",
+                "command_topic": self.screen_power_topic,
+                "state_topic": f"{self.device_id}/screen/power/state",
+                "payload_on": "ON",
+                "payload_off": "OFF",
+                "device": base_device,
+                "icon": "mdi:monitor",
+                "optimistic": False
+            }
+            self._publish_discovery("switch", "screen_power", screen_power_config)
+
+            # Screen Brightness Number
+            screen_brightness_config = {
+                "name": f"{self.device_name} Screen Brightness",
+                "unique_id": f"{self.device_id}_screen_brightness",
+                "command_topic": self.screen_brightness_topic,
+                "state_topic": f"{self.device_id}/screen/brightness/state",
+                "device": base_device,
+                "icon": "mdi:brightness-6",
+                "min": 0,
+                "max": screen_status.get("max_brightness", 255),
+                "mode": "slider"
+            }
+            self._publish_discovery("number", "screen_brightness", screen_brightness_config)
+
+    def _publish_discovery(self, component: str, config_type: str, config: dict):
+        """Helper method to publish HA discovery configs."""
+        if not self.is_enabled:
+            return
+            
+        discovery_topic = f"{self.discovery_prefix}/{component}/{self.device_id}/{config_type}/config"
+        self.client.publish(discovery_topic, json.dumps(config), retain=True)
+
+    def _publish_running_state(self, running_state=None):
+        """Helper to publish running state and button availability."""
+        if running_state is None:
+            if not self.state.current_playing_file:
+                running_state = "idle"
+            elif self.state.pause_requested:
+                running_state = "paused"
+            else:
+                running_state = "running"
+                
+        self.client.publish(self.running_state_topic, running_state, retain=True)
+        
+        # Update button availability based on state
+        self.client.publish(f"{self.device_id}/command/pause/available", 
+                          "true" if running_state == "running" else "false", 
+                          retain=True)
+        self.client.publish(f"{self.device_id}/command/play/available",
+                          "true" if running_state == "paused" else "false",
+                          retain=True)
+        # Skip is available when running and a playlist is active
+        self.client.publish(f"{self.device_id}/command/skip/available",
+                          "true" if running_state in ("running", "paused") and bool(self.state.current_playlist) else "false",
+                          retain=True)
+                          
+    def _publish_pattern_state(self, current_file=None):
+        """Helper to publish pattern state."""
+        if current_file is None:
+            current_file = self.state.current_playing_file
+            
+        if current_file:
+            if current_file.startswith('./patterns/'):
+                current_file = current_file[len('./patterns/'):]
+            else:
+                current_file = current_file.split("/")[-1].split("\\")[-1]
+            self.client.publish(f"{self.pattern_select_topic}/state", current_file, retain=True)
+        else:
+            # Clear the pattern selection
+            self.client.publish(f"{self.pattern_select_topic}/state", "None", retain=True)
+            
+    def _publish_playlist_state(self, playlist_name=None):
+        """Helper to publish playlist state."""
+        if playlist_name is None:
+            playlist_name = self.state.current_playlist_name
+            
+        if playlist_name:
+            self.client.publish(f"{self.playlist_select_topic}/state", playlist_name, retain=True)
+        else:
+            # Clear the playlist selection
+            self.client.publish(f"{self.playlist_select_topic}/state", "None", retain=True)
+            
+    def _publish_serial_state(self):
+        """Helper to publish serial state."""
+        serial_connected = (state.conn.is_connected() if state.conn else False)
+        serial_port = state.port if serial_connected else None
+        serial_status = f"connected to {serial_port}" if serial_connected else "disconnected"
+        self.client.publish(self.serial_state_topic, serial_status, retain=True)
+        
+    def _publish_progress_state(self):
+        """Helper to publish completion percentage and time remaining."""
+        if state.execution_progress:
+            current, total, remaining_time, elapsed_time = state.execution_progress
+            completion_percentage = (current / total * 100) if total > 0 else 0
+            
+            # Publish completion percentage (rounded to 1 decimal place)
+            self.client.publish(self.completion_topic, round(completion_percentage, 1), retain=True)
+            
+            # Publish time remaining (rounded to nearest second, defaulting to 0 if None)
+            time_remaining_seconds = round(remaining_time) if remaining_time is not None else 0
+            self.client.publish(self.time_remaining_topic, max(0, time_remaining_seconds), retain=True)
+        else:
+            # No pattern running, publish zeros
+            self.client.publish(self.completion_topic, 0, retain=True)
+            self.client.publish(self.time_remaining_topic, 0, retain=True)
+
+    def _publish_playlist_settings_state(self):
+        """Helper to publish playlist settings state (mode, pause_time, clear_pattern, shuffle)."""
+        self.client.publish(f"{self.device_id}/playlist/mode/state", state.playlist_mode, retain=True)
+        self.client.publish(f"{self.device_id}/playlist/pause_time/state", state.pause_time, retain=True)
+        self.client.publish(f"{self.device_id}/playlist/clear_pattern/state", state.clear_pattern, retain=True)
+        shuffle_state = "ON" if state.shuffle else "OFF"
+        self.client.publish(f"{self.device_id}/playlist/shuffle/state", shuffle_state, retain=True)
+
+    def _publish_led_state(self):
+        """Helper to publish LED state to MQTT (DW LEDs only - WLED has its own MQTT)."""
+        if not state.led_controller or state.led_provider != "board":
+            return
+
+        try:
+            status = state.led_controller.check_status()
+            if not status.get("connected", False):
+                return
+
+            # Publish power state (check both "power" for WLED compatibility and "power_on" for DW LEDs)
+            is_powered = status.get("power_on", status.get("power", False))
+            power_state = "ON" if is_powered else "OFF"
+            self.client.publish(f"{self.device_id}/led/power/state", power_state, retain=True)
+
+            # Publish brightness (convert from 0-1 to 0-100)
+            if "brightness" in status:
+                brightness = int(status["brightness"] * 100)
+                self.client.publish(f"{self.device_id}/led/brightness/state", brightness, retain=True)
+
+            # Publish effect
+            if "effect_id" in status:
+                effect_map = {
+                    0: "Static", 1: "Blink", 2: "Breathe", 3: "Wipe", 4: "Fade",
+                    5: "Scan", 6: "Dual Scan", 7: "Rainbow Cycle", 8: "Rainbow",
+                    9: "Theater Chase", 10: "Running Lights", 11: "Random Color",
+                    12: "Dynamic", 13: "Twinkle", 14: "Sparkle", 15: "Strobe",
+                    16: "Fire", 17: "Comet", 18: "Chase", 19: "Police", 20: "Lightning",
+                    21: "Fireworks", 22: "Ripple", 23: "Flow", 24: "Colorloop",
+                    25: "Palette Flow", 26: "Gradient", 27: "Multi Strobe", 28: "Waves",
+                    29: "BPM", 30: "Juggle", 31: "Meteor", 32: "Pride", 33: "Pacifica",
+                    34: "Plasma", 35: "Dissolve", 36: "Glitter", 37: "Confetti",
+                    38: "Sinelon", 39: "Candle", 40: "Aurora", 41: "Rain",
+                    42: "Halloween", 43: "Noise", 44: "Funky Plank"
+                }
+                effect_name = effect_map.get(status["effect_id"], "Static")
+                self.client.publish(f"{self.device_id}/led/effect/state", effect_name, retain=True)
+
+            # Publish speed
+            if "speed" in status:
+                self.client.publish(f"{self.device_id}/led/speed/state", status["speed"], retain=True)
+
+            # Publish intensity
+            if "intensity" in status:
+                self.client.publish(f"{self.device_id}/led/intensity/state", status["intensity"], retain=True)
+
+            # Publish color (RGB)
+            if "colors" in status and len(status["colors"]) > 0:
+                # colors is array of hex strings like ["#ff0000", "#00ff00", "#0000ff"]
+                # Convert first color to RGB dict
+                color_hex = status["colors"][0]
+                if color_hex and color_hex.startswith('#') and len(color_hex) == 7:
+                    r = int(color_hex[1:3], 16)
+                    g = int(color_hex[3:5], 16)
+                    b = int(color_hex[5:7], 16)
+                    self.client.publish(f"{self.device_id}/led/color/state",
+                                      json.dumps({"r": r, "g": g, "b": b}), retain=True)
+
+        except Exception as e:
+            logger.error(f"Error publishing LED state: {e}")
+
+    def _publish_screen_state(self):
+        """Helper to publish screen (LCD backlight) state to MQTT."""
+        if not state.screen_controller or not state.screen_controller.available:
+            return
+
+        try:
+            status = state.screen_controller.get_status()
+            power_state = "ON" if status["power_on"] else "OFF"
+            self.client.publish(f"{self.device_id}/screen/power/state", power_state, retain=True)
+            self.client.publish(f"{self.device_id}/screen/brightness/state", status["brightness"], retain=True)
+        except Exception as e:
+            logger.error(f"Error publishing screen state: {e}")
+
+    def update_state(self, current_file=None, is_running=None, playlist=None, playlist_name=None):
+        """Update state in Home Assistant. Only publishes the attributes that are explicitly passed."""
+        if not self.is_enabled:
+            return
+
+        # Update pattern state if current_file is provided
+        if current_file is not None:
+            self._publish_pattern_state(current_file)
+        
+        # Update running state and button availability if is_running is provided
+        if is_running is not None:
+            running_state = "running" if is_running else "paused" if self.state.current_playing_file else "idle"
+            self._publish_running_state(running_state)
+        
+        # Update playlist state if playlist info is provided
+        if playlist_name is not None:
+            self._publish_playlist_state(playlist_name)
+
+    def on_connect(self, client, userdata, flags, rc):
+        """Callback when connected to MQTT broker."""
+        if rc == 0:
+            self._connected = True
+            logger.info(f"MQTT Connection Accepted. client_id={self.client_id}")
+            # Subscribe to command topics
+            client.subscribe([
+                (self.command_topic, 0),
+                (self.pattern_select_topic, 0),
+                (self.playlist_select_topic, 0),
+                (self.speed_topic, 0),
+                (f"{self.device_id}/command/stop", 0),
+                (f"{self.device_id}/command/pause", 0),
+                (f"{self.device_id}/command/play", 0),
+                (f"{self.device_id}/command/skip", 0),
+                (f"{self.device_id}/playlist/mode/set", 0),
+                (f"{self.device_id}/playlist/pause_time/set", 0),
+                (f"{self.device_id}/playlist/clear_pattern/set", 0),
+                (f"{self.device_id}/playlist/shuffle/set", 0),
+                (self.led_power_topic, 0),
+                (self.led_brightness_topic, 0),
+                (self.led_effect_topic, 0),
+                (self.led_speed_topic, 0),
+                (self.led_intensity_topic, 0),
+                (self.led_color_topic, 0),
+                (self.screen_power_topic, 0),
+                (self.screen_brightness_topic, 0),
+            ])
+            # Publish discovery configurations
+            self.setup_ha_discovery()
+        else:
+            self._connected = False
+            error_messages = {
+                1: "Protocol level not supported",
+                2: "The client-identifier is not allowed by the server",
+                3: "The MQTT service is not available",
+                4: "The data in the username or password is malformed",
+                5: "The client is not authorized to connect"
+            }
+            error_msg = error_messages.get(rc, f"Unknown error code: {rc}")
+            logger.error(f"MQTT Connection Refused. {error_msg}")
+
+    def on_disconnect(self, client, userdata, rc):
+        """Callback when disconnected from MQTT broker."""
+        self._connected = False
+        if rc == 0:
+            logger.info("MQTT disconnected cleanly")
+        else:
+            # paho-mqtt MQTT_ERR_* codes — NOT broker CONNACK codes
+            err_names = {
+                1: "NOMEM", 2: "PROTOCOL", 3: "INVAL", 4: "NO_CONN",
+                5: "CONN_REFUSED", 6: "NOT_FOUND", 7: "CONN_LOST",
+                8: "TLS", 9: "PAYLOAD_SIZE", 10: "NOT_SUPPORTED",
+                11: "AUTH", 12: "ACL_DENIED", 13: "UNKNOWN", 14: "ERRNO",
+            }
+            err_name = err_names.get(rc, f"rc={rc}")
+            logger.warning(
+                f"MQTT disconnected unexpectedly: {err_name} (rc={rc}) "
+                f"client_id={self.client_id}"
+            )
+
+    def on_message(self, client, userdata, msg):
+        """Callback when message is received."""
+        try:
+            if msg.topic == self.pattern_select_topic:
+                from modules.core.pattern_manager import THETA_RHO_DIR
+                # Handle pattern selection
+                pattern_name = msg.payload.decode()
+                if pattern_name in self.patterns:
+                    # Schedule the coroutine to run in the main event loop
+                    asyncio.run_coroutine_threadsafe(
+                        self.callback_registry['run_pattern'](file_path=f"{THETA_RHO_DIR}/{pattern_name}"),
+                        self.main_loop
+                    ).add_done_callback(
+                        lambda _: self._publish_pattern_state(None)  # Clear pattern after execution
+                    )
+                    self.client.publish(f"{self.pattern_select_topic}/state", pattern_name, retain=True)
+            elif msg.topic == self.playlist_select_topic:
+                # Handle playlist selection
+                playlist_name = msg.payload.decode()
+                if playlist_name in self.playlists:
+                    # Schedule the coroutine to run in the main event loop
+                    asyncio.run_coroutine_threadsafe(
+                        self.callback_registry['run_playlist'](
+                            playlist_name=playlist_name,
+                            run_mode=self.state.playlist_mode,
+                            pause_time=self.state.pause_time,
+                            clear_pattern=self.state.clear_pattern,
+                            shuffle=self.state.shuffle
+                        ),
+                        self.main_loop
+                    ).add_done_callback(
+                        lambda _: self._publish_playlist_state(None)  # Clear playlist after execution
+                    )
+                    self.client.publish(f"{self.playlist_select_topic}/state", playlist_name, retain=True)
+            elif msg.topic == self.speed_topic:
+                speed = int(msg.payload.decode())
+                self.callback_registry['set_speed'](speed)
+            elif msg.topic == f"{self.device_id}/command/stop":
+                # Handle stop command
+                callback = self.callback_registry['stop']
+                if asyncio.iscoroutinefunction(callback):
+                    asyncio.run_coroutine_threadsafe(callback(), self.main_loop)
+                else:
+                    callback()
+                # Clear both pattern and playlist selections
+                self._publish_pattern_state(None)
+                self._publish_playlist_state(None)
+            elif msg.topic == f"{self.device_id}/command/pause":
+                # Handle pause command - only if in running state
+                if bool(self.state.current_playing_file) and not self.state.pause_requested:
+                    # Check if callback is async or sync
+                    callback = self.callback_registry['pause']
+                    if asyncio.iscoroutinefunction(callback):
+                        asyncio.run_coroutine_threadsafe(callback(), self.main_loop)
+                    else:
+                        callback()
+            elif msg.topic == f"{self.device_id}/command/play":
+                # Handle play command - only if in paused state
+                if bool(self.state.current_playing_file) and self.state.pause_requested:
+                    # Check if callback is async or sync
+                    callback = self.callback_registry['resume']
+                    if asyncio.iscoroutinefunction(callback):
+                        asyncio.run_coroutine_threadsafe(callback(), self.main_loop)
+                    else:
+                        callback()
+            elif msg.topic == f"{self.device_id}/command/skip":
+                # Handle skip command - only if a playlist is running
+                if self.state.current_playlist:
+                    callback = self.callback_registry['skip']
+                    if asyncio.iscoroutinefunction(callback):
+                        asyncio.run_coroutine_threadsafe(callback(), self.main_loop)
+                    else:
+                        callback()
+            elif msg.topic == f"{self.device_id}/playlist/mode/set":
+                mode = msg.payload.decode()
+                if mode in ["single", "loop"]:
+                    state.playlist_mode = mode
+                    self.client.publish(f"{self.device_id}/playlist/mode/state", mode, retain=True)
+            elif msg.topic == f"{self.device_id}/playlist/pause_time/set":
+                pause_time = float(msg.payload.decode())
+                if 0 <= pause_time <= 60:
+                    state.pause_time = pause_time
+                    self.client.publish(f"{self.device_id}/playlist/pause_time/state", pause_time, retain=True)
+            elif msg.topic == f"{self.device_id}/playlist/clear_pattern/set":
+                clear_pattern = msg.payload.decode()
+                if clear_pattern in ["none", "random", "adaptive", "clear_from_in", "clear_from_out", "clear_sideway"]:
+                    state.clear_pattern = clear_pattern
+                    self.client.publish(f"{self.device_id}/playlist/clear_pattern/state", clear_pattern, retain=True)
+            elif msg.topic == f"{self.device_id}/playlist/shuffle/set":
+                payload = msg.payload.decode()
+                shuffle_value = payload == "ON"
+                state.shuffle = shuffle_value
+                self.client.publish(f"{self.device_id}/playlist/shuffle/state", payload, retain=True)
+            elif msg.topic == self.led_power_topic:
+                # Handle LED power command (DW LEDs only)
+                payload = msg.payload.decode()
+                if state.led_controller and state.led_provider == "board":
+                    power_state = 1 if payload == "ON" else 0
+                    state.led_controller.set_power(power_state)
+                    # Reset idle timeout when LEDs are manually powered on via MQTT (only if idle timeout is enabled)
+                    if payload == "ON" and state.dw_led_idle_timeout_enabled:
+                        state.dw_led_last_activity_time = time.time()
+                        logger.debug("LED activity time reset due to MQTT power on")
+                    self.client.publish(f"{self.device_id}/led/power/state", payload, retain=True)
+            elif msg.topic == self.led_brightness_topic:
+                # Handle LED brightness command (DW LEDs only)
+                brightness = int(msg.payload.decode())
+                if 0 <= brightness <= 100 and state.led_controller and state.led_provider == "board":
+                    controller = state.led_controller.get_controller()
+                    if controller and hasattr(controller, 'set_brightness'):
+                        # DW LED controller expects 0-100, converts internally to 0.0-1.0
+                        controller.set_brightness(brightness)
+                        self.client.publish(f"{self.device_id}/led/brightness/state", brightness, retain=True)
+            elif msg.topic == self.led_effect_topic:
+                # Handle LED effect command (DW LEDs only)
+                effect_name = msg.payload.decode()
+                if state.led_controller and state.led_provider == "board":
+                    # Map effect name to ID
+                    effect_map = {
+                        "Static": 0, "Blink": 1, "Breathe": 2, "Wipe": 3, "Fade": 4,
+                        "Scan": 5, "Dual Scan": 6, "Rainbow Cycle": 7, "Rainbow": 8,
+                        "Theater Chase": 9, "Running Lights": 10, "Random Color": 11,
+                        "Dynamic": 12, "Twinkle": 13, "Sparkle": 14, "Strobe": 15,
+                        "Fire": 16, "Comet": 17, "Chase": 18, "Police": 19, "Lightning": 20,
+                        "Fireworks": 21, "Ripple": 22, "Flow": 23, "Colorloop": 24,
+                        "Palette Flow": 25, "Gradient": 26, "Multi Strobe": 27, "Waves": 28,
+                        "BPM": 29, "Juggle": 30, "Meteor": 31, "Pride": 32, "Pacifica": 33,
+                        "Plasma": 34, "Dissolve": 35, "Glitter": 36, "Confetti": 37,
+                        "Sinelon": 38, "Candle": 39, "Aurora": 40, "Rain": 41,
+                        "Halloween": 42, "Noise": 43, "Funky Plank": 44
+                    }
+                    effect_id = effect_map.get(effect_name)
+                    if effect_id is not None:
+                        controller = state.led_controller.get_controller()
+                        if controller and hasattr(controller, 'set_effect'):
+                            controller.set_effect(effect_id)
+                            self.client.publish(f"{self.device_id}/led/effect/state", effect_name, retain=True)
+            elif msg.topic == self.led_speed_topic:
+                # Handle LED speed command (DW LEDs only)
+                speed = int(msg.payload.decode())
+                if 0 <= speed <= 255 and state.led_controller and state.led_provider == "board":
+                    controller = state.led_controller.get_controller()
+                    if controller and hasattr(controller, 'set_speed'):
+                        controller.set_speed(speed)
+                        self.client.publish(f"{self.device_id}/led/speed/state", speed, retain=True)
+            elif msg.topic == self.led_intensity_topic:
+                # Handle LED intensity command (DW LEDs only)
+                intensity = int(msg.payload.decode())
+                if 0 <= intensity <= 255 and state.led_controller and state.led_provider == "board":
+                    controller = state.led_controller.get_controller()
+                    if controller and hasattr(controller, 'set_intensity'):
+                        controller.set_intensity(intensity)
+                        self.client.publish(f"{self.device_id}/led/intensity/state", intensity, retain=True)
+            elif msg.topic == self.led_color_topic:
+                # Handle LED color command (RGB) (DW LEDs only)
+                try:
+                    color_data = json.loads(msg.payload.decode())
+                    if state.led_controller and state.led_provider == "board" and 'r' in color_data and 'g' in color_data and 'b' in color_data:
+                        controller = state.led_controller.get_controller()
+                        if controller and hasattr(controller, 'set_color'):
+                            r, g, b = color_data['r'], color_data['g'], color_data['b']
+                            controller.set_color(r, g, b)
+                            self.client.publish(f"{self.device_id}/led/color/state",
+                                              json.dumps({"r": r, "g": g, "b": b}), retain=True)
+                except json.JSONDecodeError:
+                    logger.error(f"Invalid JSON for color command: {msg.payload}")
+            elif msg.topic == self.screen_power_topic:
+                # Handle screen power command
+                payload = msg.payload.decode()
+                if state.screen_controller and state.screen_controller.available:
+                    state.screen_controller.set_power(payload == "ON")
+                    self._publish_screen_state()
+            elif msg.topic == self.screen_brightness_topic:
+                # Handle screen brightness command
+                brightness = int(msg.payload.decode())
+                if state.screen_controller and state.screen_controller.available:
+                    state.screen_controller.set_brightness(brightness)
+                    self._publish_screen_state()
+            else:
+                # Handle other commands
+                payload = json.loads(msg.payload.decode())
+                command = payload.get('command')
+                params = payload.get('params', {})
+
+                if command in self.callback_registry:
+                    self.callback_registry[command](**params)
+                else:
+                    logger.error(f"Unknown command received: {command}")
+
+        except json.JSONDecodeError:
+            logger.error(f"Invalid JSON payload received: {msg.payload}")
+        except Exception as e:
+            logger.error(f"Error processing MQTT message: {e}")
+
+    def publish_status(self):
+        """Publish status updates periodically."""
+        while self.running:
+            try:
+                # Update all states
+                self._publish_running_state()
+                self._publish_pattern_state()
+                self._publish_playlist_state()
+                self._publish_serial_state()
+                self._publish_progress_state()
+                
+                # Update speed state
+                self.client.publish(f"{self.speed_topic}/state", self.state.speed, retain=True)
+
+                # Update LED state
+                self._publish_led_state()
+
+                # Update screen state
+                self._publish_screen_state()
+
+                # Publish keepalive status
+                status = {
+                    "timestamp": time.time(),
+                    "client_id": self.client_id
+                }
+                self.client.publish(self.status_topic, json.dumps(status))
+                
+                # Wait for next interval
+                time.sleep(self.status_interval)
+            except Exception as e:
+                logger.error(f"Error publishing status: {e}")
+                time.sleep(5)  # Wait before retry
+
+    def start(self) -> None:
+        """Start the MQTT handler."""
+        if not self.is_enabled:
+            return
+        
+        try:
+            self.client.connect(self.broker, self.port)
+            self.client.loop_start()
+            
+            # Start status publishing thread
+            self.running = True
+            self.status_thread = threading.Thread(target=self.publish_status, daemon=True)
+            self.status_thread.start()
+            
+            # Get initial pattern and playlist lists
+            self.patterns = list_theta_rho_files()
+            self.playlists = list_all_playlists()
+
+            # Wait a bit for MQTT connection to establish
+            time.sleep(1)
+            
+            # Publish initial states
+            self._publish_running_state()
+            self._publish_pattern_state()
+            self._publish_playlist_state()
+            self._publish_serial_state()
+            self._publish_progress_state()
+            self._publish_playlist_settings_state()
+            self._publish_led_state()
+            self._publish_screen_state()
+
+            # Setup Home Assistant discovery
+            self.setup_ha_discovery()
+            
+            logger.info("MQTT Handler started successfully")
+        except Exception as e:
+            logger.error(f"Failed to start MQTT Handler: {e}")
+
+    def stop(self) -> None:
+        """Stop the MQTT handler."""
+        if not self.is_enabled:
+            return
+
+        # First stop the running flag to prevent new iterations
+        self.running = False
+        
+        # Clean up status thread
+        local_status_thread = self.status_thread  # Keep a local reference
+        if local_status_thread and local_status_thread.is_alive():
+            try:
+                local_status_thread.join(timeout=5)
+                if local_status_thread.is_alive():
+                    logger.warning("MQTT status thread did not terminate cleanly")
+            except Exception as e:
+                logger.error(f"Error joining status thread: {e}")
+        self.status_thread = None
+            
+        # Clean up MQTT client
+        try:
+            if hasattr(self, 'client'):
+                self.client.loop_stop()
+                self.client.disconnect()
+        except Exception as e:
+            logger.error(f"Error disconnecting MQTT client: {e}")
+        
+        # Clean up main loop reference
+        self.main_loop = None
+        
+        logger.info("MQTT handler stopped")
+
+    @property
+    def is_enabled(self) -> bool:
+        """Return whether MQTT functionality is enabled.
+
+        MQTT is enabled if:
+        1. A broker address is configured (either via state or env var), AND
+        2. Either state.mqtt_enabled is True, OR no UI config exists (env-only mode)
+        """
+        # If no broker configured, MQTT is disabled
+        if not self.broker:
+            return False
+
+        # If state has mqtt_enabled explicitly set (UI was used), respect that setting
+        # If mqtt_broker is set in state, user configured via UI - use mqtt_enabled
+        if state.mqtt_broker:
+            return state.mqtt_enabled
+
+        # Otherwise, broker came from env vars - enable if broker exists
+        return True
+
+    @property
+    def is_connected(self) -> bool:
+        """Return whether MQTT client is currently connected to the broker."""
         return self._connected and self.is_enabled 

+ 43 - 58
modules/mqtt/utils.py

@@ -1,58 +1,43 @@
-"""MQTT utilities and callback management."""
-import os
-from typing import Dict, Callable
-from modules.core.pattern_manager import (
-    run_theta_rho_file, stop_actions, pause_execution,
-    resume_execution, THETA_RHO_DIR,
-    run_theta_rho_files, list_theta_rho_files
-)
-from modules.core.playlist_manager import get_playlist, run_playlist
-from modules.connection.connection_manager import home
-from modules.core.state import state
-
-def create_mqtt_callbacks() -> Dict[str, Callable]:
-    """Create and return the MQTT callback registry.
-    
-    Note: run_theta_rho_file and run_playlist are async functions,
-    while pause_execution, resume_execution, and stop_actions are sync functions.
-    The MQTT handler will check and handle both async and sync appropriately.
-    """
-    def set_speed(speed):
-        state.speed = speed
-
-    def skip_pattern():
-        state.skip_requested = True
-
-    return {
-        'run_pattern': run_theta_rho_file,  # async function
-        'run_playlist': run_playlist,  # async function
-        'stop': stop_actions,  # sync function
-        'pause': pause_execution,  # sync function
-        'resume': resume_execution,  # sync function
-        'skip': skip_pattern,  # sync function
-        'home': home,
-        'set_speed': set_speed
-    }
-
-def get_mqtt_state():
-    """Get the current state for MQTT updates."""
-    # Get list of pattern files
-    patterns = list_theta_rho_files()
-    
-    # Get current execution status
-    is_running = bool(state.current_playing_file) and not state.stop_requested
-    
-    # Get serial status
-    serial_connected = (state.conn.is_connected() if state.conn else False)
-    serial_port = state.port if serial_connected else None
-    serial_status = f"connected to {serial_port}" if serial_connected else "disconnected"
-    
-    return {
-        'is_running': is_running,
-        'current_file': state.current_playing_file or '',
-        'patterns': sorted(patterns),
-        'serial': serial_status,
-        'current_playlist': state.current_playlist,
-        'current_playlist_index': state.current_playlist_index,
-        'playlist_mode': state.playlist_mode
-    } 
+"""MQTT utilities and callback management."""
+from typing import Dict, Callable
+from modules.core.pattern_manager import list_theta_rho_files
+from modules.core import execution
+from modules.connection.connection_manager import home
+from modules.core.state import state
+
+def create_mqtt_callbacks() -> Dict[str, Callable]:
+    """Create and return the MQTT callback registry.
+
+    All execution actions are firmware-delegated (modules/core/execution).
+    The MQTT handler checks and handles both async and sync callables.
+    """
+    return {
+        'run_pattern': execution.run_pattern,       # async
+        'run_playlist': execution.start_playlist,   # async
+        'stop': execution.stop,                     # async
+        'pause': execution.pause,                   # async
+        'resume': execution.resume,                 # async
+        'skip': execution.skip,                     # async
+        'home': home,
+        'set_speed': execution.set_speed,           # async
+    }
+
+def get_mqtt_state():
+    """Get the current state for MQTT updates."""
+    patterns = list_theta_rho_files()
+
+    status = execution.get_cached_status()
+    is_running = bool(status.get("is_running"))
+
+    board_connected = (state.conn.is_connected() if state.conn else False)
+    board_status = f"connected to {state.port}" if board_connected else "disconnected"
+
+    return {
+        'is_running': is_running,
+        'current_file': state.current_playing_file or '',
+        'patterns': sorted(patterns),
+        'serial': board_status,
+        'current_playlist': state.current_playlist,
+        'current_playlist_index': (status.get("playlist") or {}).get("current_index"),
+        'playlist_mode': state.playlist_mode
+    }

+ 0 - 11
requirements.txt

@@ -1,8 +1,6 @@
-pyserial>=3.5
 tqdm>=4.65.0
 paho-mqtt>=1.6.1
 python-dotenv>=1.0.0
-websocket-client>=1.6.1
 fastapi>=0.100.0
 uvicorn>=0.23.0
 pydantic>=2.0.0
@@ -15,12 +13,3 @@ Pillow
 aiohttp
 pyyaml>=6.0
 zeroconf>=0.131.0  # mDNS auto-discovery of other Dune Weaver tables
-# GPIO/NeoPixel support for DW LEDs and Desert Compass
-# Note: rpi-lgpio is a drop-in replacement for RPi.GPIO that works on Pi 5
-# Do NOT install both RPi.GPIO and rpi-lgpio - they conflict
-lgpio>=0.2.2.0  # Low-level GPIO library (required by rpi-lgpio)
-rpi-lgpio>=0.4  # Provides RPi.GPIO interface, works on Pi 4 and Pi 5
-rpi-ws281x>=5.0.0  # Low-level NeoPixel/WS281x driver (Pi 4)
-adafruit-circuitpython-neopixel>=6.3.0  # Standard NeoPixel library (Pi 4)
-Adafruit-Blinka>=8.0.0  # CircuitPython compatibility layer
-Adafruit-Blinka-Raspberry-Pi5-Neopixel>=1.0.0rc1  # Pi 5 PIO-based NeoPixel driver (pre-release)

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/dist/assets/index-CHzltdTQ.css


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/dist/assets/index-Djn5LR-N.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/dist/assets/index-DpUgdjlV.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/dist/assets/index-RF2BgLvM.css


+ 2 - 2
static/dist/index.html

@@ -58,8 +58,8 @@
           .catch(function() {});
       })();
     </script>
-    <script type="module" crossorigin src="/assets/index-Djn5LR-N.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-CHzltdTQ.css">
+    <script type="module" crossorigin src="/assets/index-DpUgdjlV.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-RF2BgLvM.css">
   <script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script></head>
   <body>
     <div id="root"></div>

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/dist/sw.js


+ 0 - 1
tests/integration/__init__.py

@@ -1 +0,0 @@
-# Integration tests - hardware tests, local only

+ 0 - 144
tests/integration/conftest.py

@@ -1,144 +0,0 @@
-"""
-Integration test fixtures - Hardware detection and setup.
-
-These fixtures help determine if real hardware is available
-and provide setup/teardown for hardware-dependent tests.
-"""
-import pytest
-import serial.tools.list_ports
-
-
-def pytest_addoption(parser):
-    """Add --run-hardware option to pytest CLI."""
-    parser.addoption(
-        "--run-hardware",
-        action="store_true",
-        default=False,
-        help="Run tests that require real hardware connection"
-    )
-
-
-@pytest.fixture
-def run_hardware(request):
-    """Check if hardware tests should run."""
-    return request.config.getoption("--run-hardware")
-
-
-@pytest.fixture
-def available_serial_ports():
-    """Return list of available serial ports on this machine.
-
-    Filters out known non-hardware ports like debug consoles.
-    """
-    IGNORE_PORTS = ['/dev/cu.debug-console', '/dev/cu.Bluetooth-Incoming-Port']
-    ports = serial.tools.list_ports.comports()
-    return [port.device for port in ports if port.device not in IGNORE_PORTS]
-
-
-@pytest.fixture
-def hardware_port(available_serial_ports, run_hardware):
-    """Get a hardware port for testing, or skip if not available.
-
-    This fixture:
-    1. Checks if --run-hardware flag was passed
-    2. Checks if any serial ports are available
-    3. Returns the first available port or skips the test
-    """
-    if not run_hardware:
-        pytest.skip("Hardware tests disabled (use --run-hardware to enable)")
-
-    if not available_serial_ports:
-        pytest.skip("No serial ports available for hardware testing")
-
-    # Prefer USB ports over built-in ports
-    usb_ports = [p for p in available_serial_ports if 'usb' in p.lower() or 'USB' in p]
-    if usb_ports:
-        return usb_ports[0]
-
-    return available_serial_ports[0]
-
-
-@pytest.fixture
-def serial_connection(hardware_port):
-    """Create a real serial connection for testing.
-
-    This fixture establishes an actual serial connection to the hardware.
-    The connection is automatically closed after the test.
-    """
-    import serial
-
-    conn = serial.Serial(hardware_port, baudrate=115200, timeout=2)
-    yield conn
-    conn.close()
-
-
-@pytest.fixture(autouse=True)
-def fast_test_speed(run_hardware):
-    """Set speed to 500 for faster integration tests.
-
-    This fixture runs automatically for all integration tests.
-    Restores original speed after the test.
-    """
-    if not run_hardware:
-        yield
-        return
-
-    from modules.core.state import state
-
-    original_speed = state.speed
-    state.speed = 500  # Fast speed for tests
-
-    yield
-
-    state.speed = original_speed  # Restore original speed
-
-
-@pytest.fixture(autouse=True)
-def reset_asyncio_events(run_hardware):
-    """Reset global asyncio primitives before each test.
-
-    The pattern_manager uses global asyncio objects (Lock, Event) that are
-    bound to the event loop where they were created. When TestClient creates
-    its own event loop, these become incompatible.
-
-    This fixture resets them to None so they get recreated in the current loop.
-    Also ensures pause/stop state is cleared so tests start fresh.
-    """
-    if not run_hardware:
-        yield
-        return
-
-    import modules.core.pattern_manager as pm
-    from modules.core.state import state
-
-    # Reset pattern_manager's global async primitives
-    pm.pause_event = None
-    pm.pattern_lock = None  # Will be recreated via get_pattern_lock()
-
-    # Reset state's event loop tracking so events get recreated in new loop
-    state._event_loop = None
-    state._stop_event = None
-    state._skip_event = None
-
-    # Clear any lingering pause/stop state from previous tests
-    state._pause_requested = False
-    state._stop_requested = False
-    state._skip_requested = False
-
-    # Clear playback state
-    state.current_playing_file = None
-    state.current_playlist = None
-    state.playlist_mode = None
-    state.current_playlist_index = None
-
-    yield
-
-    # Clean up after test
-    pm.pause_event = None
-    pm.pattern_lock = None
-    state._event_loop = None
-    state._stop_event = None
-    state._skip_event = None
-    state._pause_requested = False
-    state._stop_requested = False
-    state._skip_requested = False

+ 0 - 475
tests/integration/test_hardware.py

@@ -1,475 +0,0 @@
-"""
-Integration tests for hardware communication.
-
-These tests require real hardware to be connected and are skipped by default.
-Run with: pytest tests/integration/ --run-hardware
-
-All tests in this file are marked with @pytest.mark.hardware and will
-be automatically skipped in CI environments (when CI=true).
-
-Test order matters for some tests - they build on each other:
-1. test_homing_sequence - Homes the table (required first)
-2. test_move_to_perimeter - Moves ball to edge
-3. test_move_to_center - Moves ball to center
-4. test_execute_star_pattern - Runs a full pattern
-"""
-import pytest
-import time
-import os
-import json
-import asyncio
-
-
-@pytest.mark.hardware
-class TestSerialConnection:
-    """Tests for real serial connection to sand table hardware."""
-
-    def test_serial_port_opens(self, serial_connection):
-        """Test that we can open a serial connection to the hardware."""
-        assert serial_connection.is_open
-        assert serial_connection.baudrate == 115200
-
-    def test_grbl_status_query(self, serial_connection):
-        """Test querying GRBL status with '?' command.
-
-        GRBL responds with a status string like:
-        <Idle|MPos:0.000,0.000,0.000|Bf:15,128>
-        <Run|MPos:10.000,5.000,0.000|Bf:15,128>
-        <Hold|WPos:0.000,0.000,0.000|Bf:15,128>
-
-        Note: Table may be in any state (Idle, Run, Hold, Alarm, etc.)
-        """
-        # Clear any stale data
-        serial_connection.reset_input_buffer()
-
-        # Send status query
-        serial_connection.write(b'?')
-        serial_connection.flush()
-
-        # Wait for response
-        time.sleep(0.1)
-        response = serial_connection.readline().decode().strip()
-
-        # GRBL status starts with '<' and contains position info
-        # Don't assume Idle - table could be in Run, Hold, Alarm, etc.
-        assert response.startswith('<'), f"Expected GRBL status starting with '<', got: {response}"
-        assert 'Pos:' in response, f"Expected position data (MPos or WPos) in: {response}"
-        assert '>' in response, f"Expected closing '>' in status: {response}"
-
-    def test_grbl_settings_query(self, serial_connection):
-        """Test querying GRBL settings with '$$' command.
-
-        GRBL should respond with settings like:
-        $0=10
-        $1=25
-        ...
-        ok
-        """
-        # Clear any stale data
-        serial_connection.reset_input_buffer()
-
-        # Send settings query
-        serial_connection.write(b'$$\n')
-        serial_connection.flush()
-
-        # Collect all response lines
-        responses = []
-        timeout = time.time() + 2  # 2 second timeout
-
-        while time.time() < timeout:
-            if serial_connection.in_waiting:
-                line = serial_connection.readline().decode().strip()
-                responses.append(line)
-                if line == 'ok':
-                    break
-            time.sleep(0.01)
-
-        # Should have received settings
-        assert len(responses) > 1, "Expected GRBL settings response"
-        assert responses[-1] == 'ok', f"Expected 'ok' at end, got: {responses[-1]}"
-
-        # At least some settings should start with '$'
-        settings = [r for r in responses if r.startswith('$')]
-        assert len(settings) > 0, "Expected at least one setting line"
-
-
-@pytest.mark.hardware
-class TestConnectionManager:
-    """Integration tests for the connection_manager module with real hardware."""
-
-    def test_list_serial_ports_finds_hardware(self, available_serial_ports, run_hardware):
-        """Test that list_serial_ports finds the connected hardware."""
-        if not run_hardware:
-            pytest.skip("Hardware tests disabled")
-
-        from modules.connection import connection_manager
-
-        ports = connection_manager.list_serial_ports()
-
-        # Should find at least one port
-        assert len(ports) > 0, "Expected to find at least one serial port"
-
-        # Should match what we found independently
-        for port in available_serial_ports:
-            if 'usb' in port.lower() or 'tty' in port.lower():
-                assert port in ports or any(port in p for p in ports)
-
-    def test_serial_connection_class(self, hardware_port, run_hardware):
-        """Test SerialConnection class with real hardware.
-
-        This tests the actual SerialConnection wrapper from connection_manager.
-        """
-        if not run_hardware:
-            pytest.skip("Hardware tests disabled")
-
-        from modules.connection.connection_manager import SerialConnection
-
-        conn = SerialConnection(hardware_port)
-        try:
-            assert conn.is_connected()
-
-            # Send status query
-            conn.send('?')
-            time.sleep(0.1)
-
-            response = conn.readline()
-            assert '<' in response, f"Expected GRBL status, got: {response}"
-        finally:
-            conn.close()
-
-    def test_firmware_detection(self, hardware_port, run_hardware):
-        """Test firmware type detection (FluidNC vs GRBL)."""
-        if not run_hardware:
-            pytest.skip("Hardware tests disabled")
-
-        from modules.connection.connection_manager import SerialConnection, _detect_firmware
-        from modules.core.state import state
-
-        conn = SerialConnection(hardware_port)
-        state.conn = conn
-        try:
-            firmware_type, version = _detect_firmware()
-
-            # Should detect one of the known firmware types
-            assert firmware_type in ['fluidnc', 'grbl', 'unknown'], \
-                f"Unexpected firmware type: {firmware_type}"
-
-            print(f"Detected firmware: {firmware_type} {version or ''}")
-        finally:
-            conn.close()
-            state.conn = None
-
-
-@pytest.mark.hardware
-@pytest.mark.slow
-class TestSoftReset:
-    """Tests for soft reset functionality."""
-
-    def test_soft_reset(self, hardware_port, run_hardware):
-        """Test soft reset using firmware-appropriate command.
-
-        FluidNC uses $Bye, GRBL uses Ctrl+X (0x18).
-        The test auto-detects firmware type and sends the correct command.
-        """
-        if not run_hardware:
-            pytest.skip("Hardware tests disabled")
-
-        from modules.connection.connection_manager import SerialConnection, _detect_firmware
-        from modules.core.state import state
-
-        conn = SerialConnection(hardware_port)
-        state.conn = conn
-        try:
-            # Detect firmware to determine reset command
-            firmware_type, _ = _detect_firmware()
-
-            # Clear buffer
-            conn.ser.reset_input_buffer()
-
-            # Send appropriate reset command
-            if firmware_type == 'fluidnc':
-                conn.ser.write(b'$Bye\n')
-                reset_cmd = '$Bye'
-            else:
-                conn.ser.write(b'\x18')
-                reset_cmd = 'Ctrl+X'
-
-            conn.flush()
-            print(f"Sent {reset_cmd} reset command")
-
-            # Wait for reset and startup message
-            time.sleep(1.5)
-
-            # Collect responses
-            responses = []
-            timeout = time.time() + 3
-
-            while time.time() < timeout:
-                if conn.ser.in_waiting:
-                    line = conn.ser.readline().decode().strip()
-                    if line:
-                        responses.append(line)
-                        print(f"  Response: {line}")
-                time.sleep(0.01)
-
-            # Should see GRBL/FluidNC startup message
-            all_responses = ' '.join(responses)
-            assert 'Grbl' in all_responses or 'grbl' in all_responses.lower() or 'FluidNC' in all_responses, \
-                f"Expected GRBL/FluidNC startup message, got: {responses}"
-
-        finally:
-            conn.close()
-            state.conn = None
-
-
-@pytest.mark.hardware
-@pytest.mark.slow
-class TestTableMovement:
-    """Tests for table movement operations.
-
-    IMPORTANT: These tests physically move the table!
-    Run in order: homing -> perimeter -> center -> pattern
-    """
-
-    def test_homing_sequence(self, hardware_port, run_hardware):
-        """Test full homing sequence.
-
-        This test:
-        1. Connects to hardware
-        2. Runs the homing procedure
-        3. Verifies position matches the configured homing offset
-        """
-        if not run_hardware:
-            pytest.skip("Hardware tests disabled")
-
-        import math
-        from modules.connection import connection_manager
-        from modules.core.state import state
-
-        # Connect and initialize
-        conn = connection_manager.SerialConnection(hardware_port)
-        state.conn = conn
-
-        try:
-            # Run homing (timeout 120 seconds for crash homing)
-            print("Starting homing sequence...")
-            success = connection_manager.home(timeout=120)
-
-            assert success, "Homing sequence failed"
-
-            # After homing, theta should match the configured angular_homing_offset_degrees
-            # (converted to radians), and rho should be near 0
-            expected_theta = math.radians(state.angular_homing_offset_degrees)
-            theta_diff = abs(state.current_theta - expected_theta)
-
-            assert theta_diff < 0.1, \
-                f"Expected theta near {expected_theta:.3f} rad ({state.angular_homing_offset_degrees}°), got: {state.current_theta:.3f}"
-            assert abs(state.current_rho) < 0.1, \
-                f"Expected rho near 0 after homing, got: {state.current_rho}"
-
-            print(f"Homing complete: theta={state.current_theta:.3f} rad (offset={state.angular_homing_offset_degrees}°), rho={state.current_rho:.3f}")
-
-        finally:
-            conn.close()
-            state.conn = None
-
-    def test_move_to_perimeter(self, hardware_port, run_hardware):
-        """Test moving ball to perimeter (rho=1.0) via API endpoint.
-
-        Uses the /move_to_perimeter endpoint which waits for idle before returning.
-        """
-        if not run_hardware:
-            pytest.skip("Hardware tests disabled")
-
-        from httpx import Client
-        from modules.connection import connection_manager
-        from modules.core.state import state
-
-        # Connect
-        conn = connection_manager.SerialConnection(hardware_port)
-        state.conn = conn
-
-        try:
-            # Use the API endpoint which waits for idle
-            print("Moving to perimeter via API...")
-
-            from main import app
-            from fastapi.testclient import TestClient
-
-            client = TestClient(app)
-            response = client.post("/move_to_perimeter")
-
-            assert response.status_code == 200, f"API returned {response.status_code}: {response.text}"
-            assert response.json()["success"] is True
-
-            # Verify we're near the perimeter
-            assert state.current_rho > 0.9, \
-                f"Expected rho near 1.0, got: {state.current_rho}"
-
-            print(f"At perimeter: theta={state.current_theta:.3f}, rho={state.current_rho:.3f}")
-
-        finally:
-            conn.close()
-            state.conn = None
-
-    def test_move_to_center(self, hardware_port, run_hardware):
-        """Test moving ball to center (rho=0.0) via API endpoint.
-
-        Uses the /move_to_center endpoint which waits for idle before returning.
-        """
-        if not run_hardware:
-            pytest.skip("Hardware tests disabled")
-
-        from modules.connection import connection_manager
-        from modules.core.state import state
-
-        # Connect
-        conn = connection_manager.SerialConnection(hardware_port)
-        state.conn = conn
-
-        try:
-            # Use the API endpoint which waits for idle
-            print("Moving to center via API...")
-
-            from main import app
-            from fastapi.testclient import TestClient
-
-            client = TestClient(app)
-            response = client.post("/move_to_center")
-
-            assert response.status_code == 200, f"API returned {response.status_code}: {response.text}"
-            assert response.json()["success"] is True
-
-            # Verify we're near the center
-            assert state.current_rho < 0.1, \
-                f"Expected rho near 0.0, got: {state.current_rho}"
-
-            print(f"At center: theta={state.current_theta:.3f}, rho={state.current_rho:.3f}")
-
-        finally:
-            conn.close()
-            state.conn = None
-
-    def test_execute_star_pattern(self, hardware_port, run_hardware):
-        """Test executing the star.thr pattern.
-
-        This runs a full pattern execution and verifies it completes successfully.
-        The star pattern is relatively quick and good for testing.
-        """
-        if not run_hardware:
-            pytest.skip("Hardware tests disabled")
-
-        from modules.connection import connection_manager
-        from modules.core import pattern_manager
-        from modules.core.state import state
-
-        # Connect
-        conn = connection_manager.SerialConnection(hardware_port)
-        state.conn = conn
-
-        try:
-            pattern_path = './patterns/star.thr'
-            assert os.path.exists(pattern_path), f"Pattern file not found: {pattern_path}"
-
-            print(f"Executing pattern: {pattern_path}")
-
-            async def run_pattern():
-                await pattern_manager.run_theta_rho_file(pattern_path)
-
-            asyncio.get_event_loop().run_until_complete(run_pattern())
-
-            # Pattern should have completed
-            assert state.current_playing_file is None, \
-                "Pattern should have completed (current_playing_file should be None)"
-
-            print("Pattern execution completed successfully")
-
-        finally:
-            conn.close()
-            state.conn = None
-
-
-@pytest.mark.hardware
-class TestWebSocketConnection:
-    """Tests for WebSocket connection to FluidNC."""
-
-    def test_websocket_status_endpoint(self, run_hardware):
-        """Test the /ws/status WebSocket endpoint.
-
-        This tests the FastAPI WebSocket endpoint, not direct FluidNC WebSocket.
-        """
-        if not run_hardware:
-            pytest.skip("Hardware tests disabled")
-
-        from fastapi.testclient import TestClient
-        from main import app
-
-        client = TestClient(app)
-
-        # Connect to WebSocket
-        with client.websocket_connect("/ws/status") as websocket:
-            # Should receive initial status
-            message = websocket.receive_json()
-
-            # Status format is {'type': 'status_update', 'data': {...}}
-            assert message.get("type") == "status_update", \
-                f"Expected type='status_update', got: {message}"
-
-            data = message.get("data", {})
-            assert "is_running" in data, \
-                f"Expected 'is_running' in data, got: {data.keys()}"
-
-            print(f"Received WebSocket status: {data}")
-
-
-@pytest.mark.hardware
-class TestStatePersistence:
-    """Tests for state persistence across connections."""
-
-    def test_position_saved_on_disconnect(self, hardware_port, run_hardware, tmp_path):
-        """Test that position is saved to state.json on disconnect.
-
-        This verifies the state persistence mechanism works correctly.
-        """
-        if not run_hardware:
-            pytest.skip("Hardware tests disabled")
-
-        from modules.connection import connection_manager
-        from modules.core.state import state
-
-        # Connect
-        conn = connection_manager.SerialConnection(hardware_port)
-        state.conn = conn
-
-        try:
-            # Record current position
-            initial_theta = state.current_theta
-            initial_rho = state.current_rho
-
-            # The state file path
-            state_file = './state.json'
-
-            # Disconnect (this should trigger state save)
-            conn.close()
-            state.conn = None
-
-            # Give it a moment to save
-            time.sleep(0.5)
-
-            # Verify state was saved
-            assert os.path.exists(state_file), "state.json should exist"
-
-            with open(state_file, 'r') as f:
-                saved_state = json.load(f)
-
-            # Check that position-related fields exist
-            # The exact field names depend on your state implementation
-            assert 'current_theta' in saved_state or 'theta' in saved_state or 'machine_x' in saved_state, \
-                f"Expected position data in state.json, got keys: {list(saved_state.keys())}"
-
-            print(f"State saved successfully. Position before disconnect: theta={initial_theta}, rho={initial_rho}")
-
-        finally:
-            if state.conn:
-                state.conn.close()
-                state.conn = None

+ 0 - 576
tests/integration/test_playback_controls.py

@@ -1,576 +0,0 @@
-"""
-Integration tests for playback controls.
-
-These tests verify pause, resume, stop, skip, and speed control functionality
-with real hardware connected.
-
-Run with: pytest tests/integration/test_playback_controls.py --run-hardware -v
-"""
-import pytest
-import time
-import threading
-import os
-
-
-def start_pattern_async(client, file_name="star.thr"):
-    """Helper to start a pattern in a background thread.
-
-    Returns the thread so caller can join() it after stopping.
-    """
-    def run():
-        client.post("/run_theta_rho", json={"file_name": file_name})
-
-    thread = threading.Thread(target=run)
-    thread.start()
-    return thread
-
-
-def stop_pattern(client):
-    """Helper to stop pattern execution.
-
-    Uses force_stop which doesn't wait for locks (avoids event loop issues in tests).
-    """
-    response = client.post("/force_stop")
-    return response
-
-
-@pytest.mark.hardware
-@pytest.mark.slow
-class TestPauseResume:
-    """Tests for pause and resume functionality."""
-
-    def test_pause_during_pattern(self, hardware_port, run_hardware):
-        """Test pausing execution mid-pattern.
-
-        Verifies:
-        1. Pattern starts executing
-        2. Pause request is acknowledged
-        3. Ball actually stops moving
-        """
-        if not run_hardware:
-            pytest.skip("Hardware tests disabled")
-
-        from modules.connection import connection_manager
-        from modules.core.state import state
-        from fastapi.testclient import TestClient
-        from main import app
-
-        conn = connection_manager.SerialConnection(hardware_port)
-        state.conn = conn
-
-        try:
-            client = TestClient(app)
-
-            # Start pattern in background
-            pattern_thread = start_pattern_async(client, "star.thr")
-
-            # Wait for pattern to start
-            time.sleep(3)
-            assert state.current_playing_file is not None, "Pattern should be running"
-            print(f"Pattern running: {state.current_playing_file}")
-
-            # Record position before pause
-            pos_before = (state.current_theta, state.current_rho)
-
-            # Pause execution
-            response = client.post("/pause_execution")
-            assert response.status_code == 200, f"Pause failed: {response.text}"
-            assert state.pause_requested, "pause_requested should be True"
-
-            # Wait and check ball stopped
-            time.sleep(1)
-            pos_after = (state.current_theta, state.current_rho)
-
-            theta_diff = abs(pos_after[0] - pos_before[0])
-            rho_diff = abs(pos_after[1] - pos_before[1])
-
-            print(f"Position change during pause: theta={theta_diff:.4f}, rho={rho_diff:.4f}")
-
-            # Allow small tolerance for deceleration
-            assert theta_diff < 0.5, f"Theta changed too much while paused: {theta_diff}"
-            assert rho_diff < 0.1, f"Rho changed too much while paused: {rho_diff}"
-
-            # Clean up
-            stop_pattern(client)
-            pattern_thread.join(timeout=5)
-
-        finally:
-            conn.close()
-            state.conn = None
-
-    def test_resume_after_pause(self, hardware_port, run_hardware):
-        """Test resuming execution after pause.
-
-        Verifies:
-        1. Pattern can be paused
-        2. Resume causes movement to continue
-        3. Position changes after resume
-        """
-        if not run_hardware:
-            pytest.skip("Hardware tests disabled")
-
-        from modules.connection import connection_manager
-        from modules.core.state import state
-        from fastapi.testclient import TestClient
-        from main import app
-
-        conn = connection_manager.SerialConnection(hardware_port)
-        state.conn = conn
-
-        try:
-            client = TestClient(app)
-
-            # Start pattern
-            pattern_thread = start_pattern_async(client, "star.thr")
-
-            # Wait for pattern to actually start executing (not just queued)
-            # Check that position has changed from initial, indicating movement
-            initial_pos = (state.current_theta, state.current_rho)
-            max_wait = 10  # seconds
-            started = False
-            for _ in range(max_wait * 2):  # Check every 0.5s
-                time.sleep(0.5)
-                if state.current_playing_file is not None:
-                    current_pos = (state.current_theta, state.current_rho)
-                    # Check if position changed (pattern actually moving)
-                    if (abs(current_pos[0] - initial_pos[0]) > 0.01 or
-                            abs(current_pos[1] - initial_pos[1]) > 0.01):
-                        started = True
-                        print(f"Pattern started moving: theta={current_pos[0]:.3f}, rho={current_pos[1]:.3f}")
-                        break
-
-            assert started, "Pattern should start moving within timeout"
-
-            # Pause
-            client.post("/pause_execution")
-            time.sleep(1)  # Wait for pause to take effect
-
-            pos_paused = (state.current_theta, state.current_rho)
-            print(f"Position when paused: theta={pos_paused[0]:.4f}, rho={pos_paused[1]:.4f}")
-
-            # Resume
-            response = client.post("/resume_execution")
-            assert response.status_code == 200, f"Resume failed: {response.text}"
-            assert not state.pause_requested, "pause_requested should be False after resume"
-
-            # Wait for movement after resume
-            time.sleep(3)
-
-            pos_resumed = (state.current_theta, state.current_rho)
-
-            theta_diff = abs(pos_resumed[0] - pos_paused[0])
-            rho_diff = abs(pos_resumed[1] - pos_paused[1])
-
-            print(f"Position after resume: theta={pos_resumed[0]:.4f}, rho={pos_resumed[1]:.4f}")
-            print(f"Position change after resume: theta={theta_diff:.4f}, rho={rho_diff:.4f}")
-            assert theta_diff > 0.1 or rho_diff > 0.05, "Position should change after resume"
-
-            # Clean up
-            stop_pattern(client)
-            pattern_thread.join(timeout=5)
-
-        finally:
-            conn.close()
-            state.conn = None
-
-
-@pytest.mark.hardware
-@pytest.mark.slow
-class TestStop:
-    """Tests for stop functionality."""
-
-    def test_stop_during_pattern(self, hardware_port, run_hardware):
-        """Test stopping execution mid-pattern.
-
-        Verifies:
-        1. Stop clears current_playing_file
-        2. Pattern execution actually stops
-
-        Note: Uses force_stop in test environment because regular stop_execution
-        has asyncio lock issues with TestClient's event loop handling.
-        """
-        if not run_hardware:
-            pytest.skip("Hardware tests disabled")
-
-        from modules.connection import connection_manager
-        from modules.core.state import state
-        from fastapi.testclient import TestClient
-        from main import app
-
-        conn = connection_manager.SerialConnection(hardware_port)
-        state.conn = conn
-
-        try:
-            client = TestClient(app)
-
-            # Start pattern
-            pattern_thread = start_pattern_async(client, "star.thr")
-            time.sleep(3)
-            assert state.current_playing_file is not None, "Pattern should be running"
-
-            # Stop execution (use force_stop for test reliability)
-            response = stop_pattern(client)
-            assert response.status_code == 200, f"Stop failed: {response.text}"
-
-            # Verify stopped
-            time.sleep(0.5)
-            assert state.current_playing_file is None, "current_playing_file should be None"
-
-            print("Stop completed successfully")
-            pattern_thread.join(timeout=5)
-
-        finally:
-            conn.close()
-            state.conn = None
-
-    def test_force_stop(self, hardware_port, run_hardware):
-        """Test force stop clears all state."""
-        if not run_hardware:
-            pytest.skip("Hardware tests disabled")
-
-        from modules.connection import connection_manager
-        from modules.core.state import state
-        from fastapi.testclient import TestClient
-        from main import app
-
-        conn = connection_manager.SerialConnection(hardware_port)
-        state.conn = conn
-
-        try:
-            client = TestClient(app)
-
-            # Start pattern
-            pattern_thread = start_pattern_async(client, "star.thr")
-            time.sleep(3)
-
-            # Force stop via API
-            response = client.post("/force_stop")
-            assert response.status_code == 200, f"Force stop failed: {response.text}"
-
-            time.sleep(0.5)
-
-            # Verify all state cleared
-            assert state.current_playing_file is None
-            assert state.current_playlist is None
-
-            print("Force stop completed successfully")
-            pattern_thread.join(timeout=5)
-
-        finally:
-            conn.close()
-            state.conn = None
-
-    def test_pause_then_stop(self, hardware_port, run_hardware):
-        """Test that stop works while paused."""
-        if not run_hardware:
-            pytest.skip("Hardware tests disabled")
-
-        from modules.connection import connection_manager
-        from modules.core.state import state
-        from fastapi.testclient import TestClient
-        from main import app
-
-        conn = connection_manager.SerialConnection(hardware_port)
-        state.conn = conn
-
-        try:
-            client = TestClient(app)
-
-            # Start pattern
-            pattern_thread = start_pattern_async(client, "star.thr")
-            time.sleep(3)
-
-            # Pause first
-            client.post("/pause_execution")
-            time.sleep(0.5)
-            assert state.pause_requested, "Should be paused"
-
-            # Now stop while paused
-            response = stop_pattern(client)
-            assert response.status_code == 200, f"Stop while paused failed: {response.text}"
-            assert state.current_playing_file is None, "Pattern should be stopped"
-
-            print("Stop while paused completed successfully")
-            pattern_thread.join(timeout=5)
-
-        finally:
-            conn.close()
-            state.conn = None
-
-
-@pytest.mark.hardware
-@pytest.mark.slow
-class TestSpeedControl:
-    """Tests for speed control functionality."""
-
-    def test_set_speed_during_playback(self, hardware_port, run_hardware):
-        """Test changing speed during pattern execution."""
-        if not run_hardware:
-            pytest.skip("Hardware tests disabled")
-
-        from modules.connection import connection_manager
-        from modules.core.state import state
-        from fastapi.testclient import TestClient
-        from main import app
-
-        conn = connection_manager.SerialConnection(hardware_port)
-        state.conn = conn
-
-        try:
-            client = TestClient(app)
-            original_speed = state.speed
-
-            # Start pattern
-            pattern_thread = start_pattern_async(client, "star.thr")
-            time.sleep(3)
-
-            # Change speed via API
-            new_speed = 150
-            response = client.post("/set_speed", json={"speed": new_speed})
-            assert response.status_code == 200, f"Set speed failed: {response.text}"
-            assert state.speed == new_speed, "Speed should be updated"
-
-            print(f"Speed changed from {original_speed} to {new_speed}")
-
-            # Let it run at new speed briefly
-            time.sleep(2)
-
-            # Clean up
-            stop_pattern(client)
-            pattern_thread.join(timeout=5)
-
-        finally:
-            conn.close()
-            state.conn = None
-
-    def test_speed_bounds(self, hardware_port, run_hardware):
-        """Test that invalid speed values are rejected."""
-        if not run_hardware:
-            pytest.skip("Hardware tests disabled")
-
-        from modules.connection import connection_manager
-        from modules.core.state import state
-        from fastapi.testclient import TestClient
-        from main import app
-
-        conn = connection_manager.SerialConnection(hardware_port)
-        state.conn = conn
-
-        try:
-            client = TestClient(app)
-            original_speed = state.speed
-
-            # Valid speeds should work
-            response = client.post("/set_speed", json={"speed": 50})
-            assert response.status_code == 200
-
-            response = client.post("/set_speed", json={"speed": 200})
-            assert response.status_code == 200
-
-            # Invalid speed (0 or negative) should fail
-            response = client.post("/set_speed", json={"speed": 0})
-            assert response.status_code == 400, "Speed 0 should be rejected"
-
-            response = client.post("/set_speed", json={"speed": -10})
-            assert response.status_code == 400, "Negative speed should be rejected"
-
-            # Restore
-            client.post("/set_speed", json={"speed": original_speed})
-
-        finally:
-            conn.close()
-            state.conn = None
-
-    def test_change_speed_while_paused(self, hardware_port, run_hardware):
-        """Test changing speed while paused, then resuming."""
-        if not run_hardware:
-            pytest.skip("Hardware tests disabled")
-
-        from modules.connection import connection_manager
-        from modules.core.state import state
-        from fastapi.testclient import TestClient
-        from main import app
-
-        conn = connection_manager.SerialConnection(hardware_port)
-        state.conn = conn
-
-        try:
-            client = TestClient(app)
-            original_speed = state.speed
-
-            # Start pattern
-            pattern_thread = start_pattern_async(client, "star.thr")
-            time.sleep(3)
-
-            # Pause
-            client.post("/pause_execution")
-            time.sleep(0.5)
-
-            # Change speed while paused
-            new_speed = 180
-            response = client.post("/set_speed", json={"speed": new_speed})
-            assert response.status_code == 200
-            print(f"Speed changed to {new_speed} while paused")
-
-            # Resume
-            client.post("/resume_execution")
-            time.sleep(2)
-
-            # Verify speed persisted
-            assert state.speed == new_speed, "Speed should persist after resume"
-
-            # Clean up
-            stop_pattern(client)
-            pattern_thread.join(timeout=5)
-
-            # Restore original speed
-            state.speed = original_speed
-
-        finally:
-            conn.close()
-            state.conn = None
-
-
-@pytest.mark.hardware
-@pytest.mark.slow
-class TestSkip:
-    """Tests for skip pattern functionality."""
-
-    def test_skip_pattern_in_playlist(self, hardware_port, run_hardware):
-        """Test skipping to next pattern in playlist."""
-        if not run_hardware:
-            pytest.skip("Hardware tests disabled")
-
-        from modules.connection import connection_manager
-        from modules.core import playlist_manager
-        from modules.core.state import state
-        from fastapi.testclient import TestClient
-        from main import app
-
-        conn = connection_manager.SerialConnection(hardware_port)
-        state.conn = conn
-
-        try:
-            client = TestClient(app)
-
-            # Create test playlist with 2 patterns
-            test_playlist_name = "_test_skip_playlist"
-            patterns = ["star.thr", "circle_normalized.thr"]
-
-            existing_patterns = [p for p in patterns if os.path.exists(f"./patterns/{p}")]
-            if len(existing_patterns) < 2:
-                pytest.skip("Need at least 2 patterns for skip test")
-
-            playlist_manager.create_playlist(test_playlist_name, existing_patterns)
-
-            try:
-                # Run playlist in background
-                def run_playlist():
-                    client.post("/run_playlist", json={
-                        "playlist_name": test_playlist_name,
-                        "pause_time": 0,
-                        "run_mode": "single"
-                    })
-
-                playlist_thread = threading.Thread(target=run_playlist)
-                playlist_thread.start()
-
-                # Wait for first pattern to start
-                time.sleep(3)
-
-                first_pattern = state.current_playing_file
-                print(f"First pattern: {first_pattern}")
-                assert first_pattern is not None
-
-                # Skip to next pattern
-                response = client.post("/skip_pattern")
-                assert response.status_code == 200, f"Skip failed: {response.text}"
-
-                # Wait for skip to process
-                time.sleep(3)
-
-                second_pattern = state.current_playing_file
-                print(f"After skip: {second_pattern}")
-
-                # Pattern should have changed (or playlist ended)
-                if second_pattern is not None:
-                    assert second_pattern != first_pattern or state.current_playlist_index > 0
-
-                # Clean up
-                stop_pattern(client)
-                playlist_thread.join(timeout=5)
-
-            finally:
-                playlist_manager.delete_playlist(test_playlist_name)
-
-        finally:
-            conn.close()
-            state.conn = None
-
-    def test_skip_while_paused(self, hardware_port, run_hardware):
-        """Test that skip works while paused."""
-        if not run_hardware:
-            pytest.skip("Hardware tests disabled")
-
-        from modules.connection import connection_manager
-        from modules.core import playlist_manager
-        from modules.core.state import state
-        from fastapi.testclient import TestClient
-        from main import app
-
-        conn = connection_manager.SerialConnection(hardware_port)
-        state.conn = conn
-
-        try:
-            client = TestClient(app)
-
-            # Create test playlist
-            test_playlist_name = "_test_skip_paused"
-            patterns = ["star.thr", "circle_normalized.thr"]
-
-            existing_patterns = [p for p in patterns if os.path.exists(f"./patterns/{p}")]
-            if len(existing_patterns) < 2:
-                pytest.skip("Need at least 2 patterns")
-
-            playlist_manager.create_playlist(test_playlist_name, existing_patterns)
-
-            try:
-                # Run playlist
-                def run_playlist():
-                    client.post("/run_playlist", json={
-                        "playlist_name": test_playlist_name,
-                        "run_mode": "single"
-                    })
-
-                playlist_thread = threading.Thread(target=run_playlist)
-                playlist_thread.start()
-
-                time.sleep(3)
-
-                # Pause
-                client.post("/pause_execution")
-                time.sleep(0.5)
-                assert state.pause_requested
-
-                first_pattern = state.current_playing_file
-
-                # Skip while paused
-                response = client.post("/skip_pattern")
-                assert response.status_code == 200
-
-                # Resume to allow skip to process
-                client.post("/resume_execution")
-                time.sleep(3)
-
-                print(f"Skipped from {first_pattern} while paused")
-
-                # Clean up
-                stop_pattern(client)
-                playlist_thread.join(timeout=5)
-
-            finally:
-                playlist_manager.delete_playlist(test_playlist_name)
-
-        finally:
-            conn.close()
-            state.conn = None

+ 0 - 529
tests/integration/test_playlist.py

@@ -1,529 +0,0 @@
-"""
-Integration tests for playlist functionality.
-
-These tests verify playlist playback modes, clear patterns,
-pause between patterns, and state updates.
-
-Run with: pytest tests/integration/test_playlist.py --run-hardware -v
-"""
-import pytest
-import time
-import threading
-import os
-
-
-def start_playlist_async(client, playlist_name, pause_time=1, run_mode="single",
-                          clear_pattern=None, shuffle=False):
-    """Helper to start a playlist in a background thread.
-
-    Returns the thread so caller can join() it after stopping.
-    """
-    def run():
-        payload = {
-            "playlist_name": playlist_name,
-            "pause_time": pause_time,
-            "run_mode": run_mode
-        }
-        if clear_pattern:
-            payload["clear_pattern"] = clear_pattern
-        if shuffle:
-            payload["shuffle"] = shuffle
-        client.post("/run_playlist", json=payload)
-
-    thread = threading.Thread(target=run)
-    thread.start()
-    return thread
-
-
-def start_pattern_async(client, file_name="star.thr"):
-    """Helper to start a pattern in a background thread.
-
-    Returns the thread so caller can join() it after stopping.
-    """
-    def run():
-        client.post("/run_theta_rho", json={"file_name": file_name})
-
-    thread = threading.Thread(target=run)
-    thread.start()
-    return thread
-
-
-def stop_pattern(client):
-    """Helper to stop pattern execution.
-
-    Uses force_stop which doesn't wait for locks (avoids event loop issues in tests).
-    """
-    response = client.post("/force_stop")
-    return response
-
-
-@pytest.fixture
-def test_playlist(run_hardware):
-    """Create a test playlist and clean it up after the test."""
-    if not run_hardware:
-        pytest.skip("Hardware tests disabled")
-
-    from modules.core import playlist_manager
-
-    playlist_name = "_integration_test_playlist"
-
-    # Use specific simple patterns for testing
-    test_patterns = [
-        "star.thr",
-        "circle_normalized.thr",
-        "square.thr"
-    ]
-
-    # Verify patterns exist
-    available_patterns = []
-    for pattern in test_patterns:
-        if os.path.exists(f"./patterns/{pattern}"):
-            available_patterns.append(pattern)
-
-    if len(available_patterns) < 2:
-        pytest.skip(f"Need at least 2 of these patterns: {test_patterns}")
-
-    # Create the playlist
-    playlist_manager.create_playlist(playlist_name, available_patterns)
-
-    yield {
-        "name": playlist_name,
-        "patterns": available_patterns,
-        "count": len(available_patterns)
-    }
-
-    # Cleanup
-    playlist_manager.delete_playlist(playlist_name)
-
-
-@pytest.mark.hardware
-@pytest.mark.slow
-class TestPlaylistModes:
-    """Tests for different playlist run modes."""
-
-    def test_run_playlist_single_mode(self, hardware_port, run_hardware, test_playlist):
-        """Test playlist in single mode - plays all patterns once then stops."""
-        from modules.connection import connection_manager
-        from modules.core.state import state
-        from fastapi.testclient import TestClient
-        from main import app
-
-        conn = connection_manager.SerialConnection(hardware_port)
-        state.conn = conn
-
-        try:
-            client = TestClient(app)
-
-            print(f"Test playlist: {test_playlist}")
-
-            # Try direct API call first to see response
-            response = client.post("/run_playlist", json={
-                "playlist_name": test_playlist["name"],
-                "pause_time": 1,
-                "run_mode": "single"
-            })
-            print(f"API response: {response.status_code} - {response.text}")
-
-            # Wait for it to start
-            time.sleep(3)
-
-            print(f"state.current_playlist = {state.current_playlist}")
-            print(f"state.playlist_mode = {state.playlist_mode}")
-            print(f"state.current_playing_file = {state.current_playing_file}")
-
-            assert state.current_playlist is not None, "Playlist should be running"
-            assert state.playlist_mode == "single", f"Mode should be 'single', got: {state.playlist_mode}"
-
-            print(f"Playlist running in single mode with {test_playlist['count']} patterns")
-
-            # Clean up
-            stop_pattern(client)
-
-        finally:
-            conn.close()
-            state.conn = None
-
-    def test_run_playlist_loop_mode(self, hardware_port, run_hardware, test_playlist):
-        """Test playlist in loop mode - continues from start after last pattern."""
-        from modules.connection import connection_manager
-        from modules.core.state import state
-        from fastapi.testclient import TestClient
-        from main import app
-
-        conn = connection_manager.SerialConnection(hardware_port)
-        state.conn = conn
-
-        try:
-            client = TestClient(app)
-
-            # Start playlist in background
-            playlist_thread = start_playlist_async(
-                client,
-                test_playlist["name"],
-                pause_time=1,
-                run_mode="loop"
-            )
-
-            time.sleep(3)
-
-            assert state.playlist_mode == "loop", f"Mode should be 'loop', got: {state.playlist_mode}"
-
-            print("Playlist running in loop mode")
-
-            # Clean up
-            stop_pattern(client)
-            playlist_thread.join(timeout=5)
-
-        finally:
-            conn.close()
-            state.conn = None
-
-    def test_run_playlist_shuffle(self, hardware_port, run_hardware, test_playlist):
-        """Test playlist shuffle mode randomizes order."""
-        from modules.connection import connection_manager
-        from modules.core.state import state
-        from fastapi.testclient import TestClient
-        from main import app
-
-        conn = connection_manager.SerialConnection(hardware_port)
-        state.conn = conn
-
-        try:
-            client = TestClient(app)
-
-            # Start playlist in background with shuffle
-            playlist_thread = start_playlist_async(
-                client,
-                test_playlist["name"],
-                pause_time=1,
-                run_mode="single",
-                shuffle=True
-            )
-
-            time.sleep(3)
-
-            # Playlist should be running
-            assert state.current_playlist is not None
-
-            print(f"Playlist running with shuffle enabled")
-            print(f"Current pattern: {state.current_playing_file}")
-            print(f"Playlist order: {state.current_playlist}")
-
-            # Clean up
-            stop_pattern(client)
-            playlist_thread.join(timeout=5)
-
-        finally:
-            conn.close()
-            state.conn = None
-
-
-@pytest.mark.hardware
-@pytest.mark.slow
-class TestPlaylistPause:
-    """Tests for pause time between patterns."""
-
-    def test_playlist_pause_between_patterns(self, hardware_port, run_hardware, test_playlist):
-        """Test that pause_time is respected between patterns."""
-        from modules.connection import connection_manager
-        from modules.core.state import state
-        from fastapi.testclient import TestClient
-        from main import app
-
-        conn = connection_manager.SerialConnection(hardware_port)
-        state.conn = conn
-
-        try:
-            client = TestClient(app)
-            pause_time = 5  # 5 seconds between patterns
-
-            # Start playlist in background
-            playlist_thread = start_playlist_async(
-                client,
-                test_playlist["name"],
-                pause_time=pause_time,
-                run_mode="single"
-            )
-
-            # Wait for first pattern to complete (this may take a while)
-            # For testing, we'll just verify the pause_time setting is stored
-            time.sleep(3)
-
-            # Check that pause_time_remaining is used during transitions
-            # (We can't easily wait for pattern completion in a test)
-            print(f"Playlist started with pause_time={pause_time}s")
-            print(f"Current pause_time_remaining: {state.pause_time_remaining}")
-
-            # Clean up
-            stop_pattern(client)
-            playlist_thread.join(timeout=5)
-
-        finally:
-            conn.close()
-            state.conn = None
-
-    def test_stop_during_playlist_pause(self, hardware_port, run_hardware, test_playlist):
-        """Test that stop works during the pause between patterns."""
-        from modules.connection import connection_manager
-        from modules.core.state import state
-        from fastapi.testclient import TestClient
-        from main import app
-
-        conn = connection_manager.SerialConnection(hardware_port)
-        state.conn = conn
-
-        try:
-            client = TestClient(app)
-
-            # Start playlist with long pause
-            playlist_thread = start_playlist_async(
-                client,
-                test_playlist["name"],
-                pause_time=30,  # Long pause
-                run_mode="single"
-            )
-
-            time.sleep(3)
-
-            # Stop (whether during pattern or pause)
-            response = stop_pattern(client)
-            assert response.status_code == 200, f"Stop failed: {response.text}"
-
-            time.sleep(0.5)
-            assert state.current_playlist is None, "Playlist should be stopped"
-
-            print("Successfully stopped during playlist")
-            playlist_thread.join(timeout=5)
-
-        finally:
-            conn.close()
-            state.conn = None
-
-
-@pytest.mark.hardware
-@pytest.mark.slow
-class TestPlaylistClearPattern:
-    """Tests for clear pattern functionality between patterns."""
-
-    def test_playlist_with_clear_pattern(self, hardware_port, run_hardware, test_playlist):
-        """Test that clear pattern runs between main patterns."""
-        from modules.connection import connection_manager
-        from modules.core.state import state
-        from fastapi.testclient import TestClient
-        from main import app
-
-        conn = connection_manager.SerialConnection(hardware_port)
-        state.conn = conn
-
-        try:
-            client = TestClient(app)
-
-            # Start playlist with clear pattern
-            playlist_thread = start_playlist_async(
-                client,
-                test_playlist["name"],
-                pause_time=1,
-                clear_pattern="clear_from_in",
-                run_mode="single"
-            )
-
-            time.sleep(3)
-
-            assert state.current_playlist is not None
-
-            print("Playlist running with clear_pattern='clear_from_in'")
-
-            # Clean up
-            stop_pattern(client)
-            playlist_thread.join(timeout=5)
-
-        finally:
-            conn.close()
-            state.conn = None
-
-
-@pytest.mark.hardware
-@pytest.mark.slow
-class TestPlaylistStateUpdates:
-    """Tests for state updates during playlist playback."""
-
-    def test_current_file_updates(self, hardware_port, run_hardware, test_playlist):
-        """Test that current_playing_file reflects the active pattern."""
-        from modules.connection import connection_manager
-        from modules.core.state import state
-        from fastapi.testclient import TestClient
-        from main import app
-
-        conn = connection_manager.SerialConnection(hardware_port)
-        state.conn = conn
-
-        try:
-            client = TestClient(app)
-
-            # Start playlist in background
-            playlist_thread = start_playlist_async(
-                client,
-                test_playlist["name"],
-                pause_time=1,
-                run_mode="single"
-            )
-
-            time.sleep(3)
-
-            # current_playing_file should be set
-            assert state.current_playing_file is not None, \
-                "current_playing_file should be set during playback"
-
-            # Should be one of the playlist patterns
-            current = state.current_playing_file
-            print(f"Current playing file: {current}")
-
-            # Normalize paths for comparison
-            playlist_patterns = [os.path.normpath(p) for p in test_playlist["patterns"]]
-            current_normalized = os.path.normpath(current) if current else None
-
-            # The current file should be related to one of the playlist patterns
-            # (path may differ slightly based on how it's resolved)
-            assert current is not None, "Should have a current playing file"
-
-            # Clean up
-            stop_pattern(client)
-            playlist_thread.join(timeout=5)
-
-        finally:
-            conn.close()
-            state.conn = None
-
-    def test_playlist_index_updates(self, hardware_port, run_hardware, test_playlist):
-        """Test that current_playlist_index updates correctly."""
-        from modules.connection import connection_manager
-        from modules.core.state import state
-        from fastapi.testclient import TestClient
-        from main import app
-
-        conn = connection_manager.SerialConnection(hardware_port)
-        state.conn = conn
-
-        try:
-            client = TestClient(app)
-
-            # Start playlist in background
-            playlist_thread = start_playlist_async(
-                client,
-                test_playlist["name"],
-                pause_time=1,
-                run_mode="single"
-            )
-
-            time.sleep(3)
-
-            # Index should be set
-            assert state.current_playlist_index is not None, \
-                "current_playlist_index should be set"
-            assert state.current_playlist_index >= 0, \
-                "Index should be non-negative"
-
-            print(f"Current playlist index: {state.current_playlist_index}")
-            print(f"Playlist length: {len(state.current_playlist) if state.current_playlist else 0}")
-
-            # Clean up
-            stop_pattern(client)
-            playlist_thread.join(timeout=5)
-
-        finally:
-            conn.close()
-            state.conn = None
-
-    def test_progress_updates(self, hardware_port, run_hardware):
-        """Test that execution_progress updates during pattern execution."""
-        from modules.connection import connection_manager
-        from modules.core.state import state
-        from fastapi.testclient import TestClient
-        from main import app
-
-        conn = connection_manager.SerialConnection(hardware_port)
-        state.conn = conn
-
-        try:
-            client = TestClient(app)
-
-            # Start pattern in background
-            pattern_thread = start_pattern_async(client, "star.thr")
-
-            # Wait for pattern to start
-            time.sleep(2)
-
-            # Check progress
-            progress_samples = []
-            for _ in range(5):
-                if state.execution_progress:
-                    progress_samples.append(state.execution_progress)
-                    print(f"Progress: {state.execution_progress}")
-                time.sleep(1)
-
-            # Should have captured some progress
-            assert len(progress_samples) > 0, "Should have recorded some progress updates"
-
-            # Progress should be changing (pattern executing)
-            if len(progress_samples) > 1:
-                first = progress_samples[0]
-                last = progress_samples[-1]
-                # Progress is typically a dict with 'current' and 'total'
-                if isinstance(first, dict) and isinstance(last, dict):
-                    print(f"Progress went from {first} to {last}")
-
-            # Clean up
-            stop_pattern(client)
-            pattern_thread.join(timeout=5)
-
-        finally:
-            conn.close()
-            state.conn = None
-
-
-@pytest.mark.hardware
-class TestWebSocketStatus:
-    """Tests for WebSocket status updates during playback."""
-
-    def test_status_updates_during_playback(self, hardware_port, run_hardware):
-        """Test that WebSocket broadcasts correct state during playback."""
-        if not run_hardware:
-            pytest.skip("Hardware tests disabled")
-
-        from fastapi.testclient import TestClient
-        from modules.connection import connection_manager
-        from modules.core.state import state
-        from main import app
-
-        conn = connection_manager.SerialConnection(hardware_port)
-        state.conn = conn
-
-        try:
-            client = TestClient(app)
-
-            # Start pattern in background
-            pattern_thread = start_pattern_async(client, "star.thr")
-
-            time.sleep(2)
-
-            # Check WebSocket status
-            with client.websocket_connect("/ws/status") as websocket:
-                message = websocket.receive_json()
-
-                # Status format is {'type': 'status_update', 'data': {...}}
-                assert message.get("type") == "status_update", \
-                    f"Expected type='status_update', got: {message}"
-
-                data = message.get("data", {})
-                print(f"WebSocket status: {data}")
-
-                # Should have expected status fields
-                assert "is_running" in data, f"Expected 'is_running' in data"
-
-            # Clean up
-            stop_pattern(client)
-            pattern_thread.join(timeout=5)
-
-        finally:
-            conn.close()
-            state.conn = None

+ 8 - 21
tests/unit/test_api_patterns.py

@@ -219,7 +219,7 @@ class TestStopExecution:
         mock_state.conn.is_connected.return_value = True
 
         with patch("main.state", mock_state):
-            with patch("main.pattern_manager.stop_actions", new_callable=AsyncMock, return_value=True):
+            with patch("main.execution.stop", new_callable=AsyncMock, return_value=True):
                 response = await async_client.post("/stop_execution")
 
         assert response.status_code == 200
@@ -245,9 +245,8 @@ class TestPauseResumeExecution:
     @pytest.mark.asyncio
     async def test_pause_execution(self, async_client):
         """Test pause_execution endpoint."""
-        # Mock check_table_is_idle to return False (something is playing)
-        with patch("main.pattern_manager.check_table_is_idle", return_value=False):
-            with patch("main.pattern_manager.pause_execution", return_value=True):
+        with patch("main.execution.get_cached_status", return_value={"is_running": True}):
+            with patch("main.execution.pause", new_callable=AsyncMock):
                 response = await async_client.post("/pause_execution")
 
         assert response.status_code == 200
@@ -257,8 +256,8 @@ class TestPauseResumeExecution:
     @pytest.mark.asyncio
     async def test_pause_execution_when_idle(self, async_client):
         """Test pause_execution returns 400 when nothing is playing."""
-        # Mock check_table_is_idle to return True (table is idle)
-        with patch("main.pattern_manager.check_table_is_idle", return_value=True):
+        with patch("main.execution.get_cached_status",
+                   return_value={"is_running": False, "pause_time_remaining": 0}):
             response = await async_client.post("/pause_execution")
 
         assert response.status_code == 400
@@ -268,15 +267,9 @@ class TestPauseResumeExecution:
     @pytest.mark.asyncio
     async def test_resume_execution(self, async_client):
         """Test resume_execution endpoint."""
-        # Mock state.pause_requested to True (execution is paused)
-        from main import state
-        original_value = state.pause_requested
-        try:
-            state.pause_requested = True
-            with patch("main.pattern_manager.resume_execution", return_value=True):
+        with patch("main.execution.get_cached_status", return_value={"is_paused": True}):
+            with patch("main.execution.resume", new_callable=AsyncMock):
                 response = await async_client.post("/resume_execution")
-        finally:
-            state.pause_requested = original_value
 
         assert response.status_code == 200
         data = response.json()
@@ -285,14 +278,8 @@ class TestPauseResumeExecution:
     @pytest.mark.asyncio
     async def test_resume_execution_when_not_paused(self, async_client):
         """Test resume_execution returns 400 when not paused."""
-        # Mock state.pause_requested to False (not paused)
-        from main import state
-        original_value = state.pause_requested
-        try:
-            state.pause_requested = False
+        with patch("main.execution.get_cached_status", return_value={"is_paused": False}):
             response = await async_client.post("/resume_execution")
-        finally:
-            state.pause_requested = original_value
 
         assert response.status_code == 400
         data = response.json()

+ 36 - 26
tests/unit/test_api_playlists.py

@@ -222,10 +222,7 @@ class TestRunPlaylist:
 
     @pytest.mark.asyncio
     async def test_run_playlist_when_disconnected(self, async_client, mock_state):
-        """Test run_playlist fails when not connected.
-
-        Note: The endpoint catches HTTPException and re-raises as 500.
-        """
+        """Test run_playlist fails when not connected."""
         mock_state.conn = None
         mock_state.is_homing = False
 
@@ -240,17 +237,13 @@ class TestRunPlaylist:
                 }
             )
 
-        # Endpoint wraps in try/except and returns 500
-        assert response.status_code == 500
+        assert response.status_code == 400
         data = response.json()
         assert "not established" in data["detail"].lower()
 
     @pytest.mark.asyncio
     async def test_run_playlist_during_homing(self, async_client, mock_state):
-        """Test run_playlist fails during homing.
-
-        Note: The endpoint catches HTTPException and re-raises as 500.
-        """
+        """Test run_playlist fails during homing."""
         mock_state.is_homing = True
         mock_state.conn = MagicMock()
         mock_state.conn.is_connected.return_value = True
@@ -266,37 +259,54 @@ class TestRunPlaylist:
                 }
             )
 
-        # Endpoint wraps in try/except and returns 500
-        assert response.status_code == 500
+        assert response.status_code == 409
         data = response.json()
         assert "homing" in data["detail"].lower()
 
+    @pytest.mark.asyncio
+    async def test_run_playlist_delegates_to_board(self, async_client, mock_state):
+        """Test run_playlist hands the run to the firmware-delegation layer."""
+        mock_state.is_homing = False
+        mock_state.conn = MagicMock()
+        mock_state.conn.is_connected.return_value = True
+
+        with patch("main.state", mock_state), \
+             patch("main.execution.start_playlist", new_callable=AsyncMock) as start:
+            response = await async_client.post(
+                "/run_playlist",
+                json={
+                    "playlist_name": "test",
+                    "pause_time": 5,
+                    "clear_pattern": "adaptive",
+                    "run_mode": "indefinite",
+                    "shuffle": True
+                }
+            )
+
+        assert response.status_code == 200
+        start.assert_awaited_once_with(
+            "test", run_mode="indefinite", pause_time=5,
+            clear_pattern="adaptive", shuffle=True,
+        )
+
 
 class TestSkipPattern:
     """Tests for /skip_pattern endpoint."""
 
     @pytest.mark.asyncio
-    async def test_skip_pattern(self, async_client, mock_state):
-        """Test skip_pattern during playlist execution."""
-        mock_state.current_playlist = ["a.thr", "b.thr"]
-        mock_state.current_playlist_index = 0
-        mock_state.skip_requested = False
-
-        with patch("main.state", mock_state):
+    async def test_skip_pattern(self, async_client):
+        """Test skip_pattern delegates to the board."""
+        with patch("main.execution.skip", new_callable=AsyncMock, return_value=True):
             response = await async_client.post("/skip_pattern")
 
         assert response.status_code == 200
         data = response.json()
         assert data["success"] is True
-        # Endpoint sets skip_requested directly
-        assert mock_state.skip_requested is True
 
     @pytest.mark.asyncio
-    async def test_skip_pattern_no_playlist(self, async_client, mock_state):
-        """Test skip_pattern fails when no playlist is running."""
-        mock_state.current_playlist = None
-
-        with patch("main.state", mock_state):
+    async def test_skip_pattern_no_playlist(self, async_client):
+        """Test skip_pattern fails when nothing is running."""
+        with patch("main.execution.skip", new_callable=AsyncMock, return_value=False):
             response = await async_client.post("/skip_pattern")
 
         assert response.status_code == 400

+ 184 - 270
tests/unit/test_api_status.py

@@ -1,270 +1,184 @@
-"""
-Unit tests for status and info API endpoints.
-
-Tests the following endpoints:
-- GET /serial_status
-- GET /list_serial_ports
-- GET /api/settings
-- GET /api/table-info
-"""
-import pytest
-from unittest.mock import patch, MagicMock, AsyncMock
-
-
-class TestSerialStatus:
-    """Tests for /serial_status endpoint."""
-
-    @pytest.mark.asyncio
-    async def test_serial_status_when_connected(self, async_client, mock_state):
-        """Test serial_status returns connected state."""
-        mock_state.conn = MagicMock()
-        mock_state.conn.is_connected.return_value = True
-        mock_state.port = "/dev/ttyUSB0"
-        mock_state.preferred_port = "__auto__"
-
-        with patch("main.state", mock_state):
-            response = await async_client.get("/serial_status")
-
-        assert response.status_code == 200
-        data = response.json()
-        assert data["connected"] is True
-        assert data["port"] == "/dev/ttyUSB0"
-        assert data["preferred_port"] == "__auto__"
-
-    @pytest.mark.asyncio
-    async def test_serial_status_when_disconnected(self, async_client, mock_state):
-        """Test serial_status returns disconnected state."""
-        mock_state.conn = None
-        mock_state.port = None
-        mock_state.preferred_port = "__none__"
-
-        with patch("main.state", mock_state):
-            response = await async_client.get("/serial_status")
-
-        assert response.status_code == 200
-        data = response.json()
-        assert data["connected"] is False
-        assert data["port"] is None
-        assert data["preferred_port"] == "__none__"
-
-    @pytest.mark.asyncio
-    async def test_serial_status_with_disconnected_conn(self, async_client, mock_state):
-        """Test serial_status when conn exists but is disconnected."""
-        mock_state.conn = MagicMock()
-        mock_state.conn.is_connected.return_value = False
-        mock_state.port = "/dev/ttyUSB0"
-        mock_state.preferred_port = "/dev/ttyUSB0"
-
-        with patch("main.state", mock_state):
-            response = await async_client.get("/serial_status")
-
-        assert response.status_code == 200
-        data = response.json()
-        assert data["connected"] is False
-
-
-class TestListSerialPorts:
-    """Tests for /list_serial_ports endpoint."""
-
-    @pytest.mark.asyncio
-    async def test_list_serial_ports_returns_list(self, async_client):
-        """Test list_serial_ports returns a list of available ports."""
-        mock_ports = ["/dev/ttyUSB0", "/dev/ttyACM0"]
-
-        with patch("main.connection_manager.list_serial_ports", return_value=mock_ports):
-            response = await async_client.get("/list_serial_ports")
-
-        assert response.status_code == 200
-        data = response.json()
-        assert isinstance(data, list)
-        assert "/dev/ttyUSB0" in data
-        assert "/dev/ttyACM0" in data
-
-    @pytest.mark.asyncio
-    async def test_list_serial_ports_empty(self, async_client):
-        """Test list_serial_ports returns empty list when no ports."""
-        with patch("main.connection_manager.list_serial_ports", return_value=[]):
-            response = await async_client.get("/list_serial_ports")
-
-        assert response.status_code == 200
-        data = response.json()
-        assert data == []
-
-
-class TestGetAllSettings:
-    """Tests for /api/settings endpoint."""
-
-    @pytest.mark.asyncio
-    async def test_get_all_settings_returns_expected_structure(self, async_client, mock_state):
-        """Test get_all_settings returns complete settings structure."""
-        mock_state.app_name = "Test Table"
-        mock_state.custom_logo = None
-        mock_state.preferred_port = "__auto__"
-        mock_state.clear_pattern_speed = 150
-        mock_state.custom_clear_from_in = None
-        mock_state.custom_clear_from_out = None
-        mock_state.auto_play_enabled = False
-        mock_state.auto_play_playlist = None
-        mock_state.auto_play_run_mode = "single"
-        mock_state.auto_play_pause_time = 0
-        mock_state.auto_play_clear_pattern = None
-        mock_state.auto_play_shuffle = False
-        mock_state.scheduled_pause_enabled = False
-        mock_state.scheduled_pause_control_wled = False
-        mock_state.scheduled_pause_finish_pattern = False
-        mock_state.scheduled_pause_timezone = None
-        mock_state.scheduled_pause_time_slots = []
-        mock_state.homing = 0
-        mock_state.homing_user_override = False
-        mock_state.angular_homing_offset_degrees = 0.0
-        mock_state.auto_home_enabled = False
-        mock_state.auto_home_after_patterns = 10
-        mock_state.led_provider = "none"
-        mock_state.wled_ip = None
-        mock_state.dw_led_num_leds = 60
-        mock_state.dw_led_gpio_pin = 18
-        mock_state.dw_led_pixel_order = "GRB"
-        mock_state.dw_led_brightness = 50
-        mock_state.dw_led_speed = 128
-        mock_state.dw_led_intensity = 128
-        mock_state.dw_led_idle_effect = "solid"
-        mock_state.dw_led_playing_effect = "rainbow"
-        mock_state.dw_led_idle_timeout_enabled = False
-        mock_state.dw_led_idle_timeout_minutes = 30
-        mock_state.mqtt_enabled = False
-        mock_state.mqtt_broker = None
-        mock_state.mqtt_port = 1883
-        mock_state.mqtt_username = None
-        mock_state.mqtt_password = None
-        mock_state.mqtt_client_id = "dune_weaver"
-        mock_state.mqtt_discovery_prefix = "homeassistant"
-        mock_state.mqtt_device_id = "dune_weaver_01"
-        mock_state.mqtt_device_name = "Dune Weaver"
-        mock_state.table_type = "dune_weaver"
-        mock_state.table_type_override = None
-        mock_state.gear_ratio = 10.0
-        mock_state.x_steps_per_mm = 200.0
-        mock_state.y_steps_per_mm = 287.0
-        mock_state.timezone = "UTC"
-
-        with patch("main.state", mock_state):
-            response = await async_client.get("/api/settings")
-
-        assert response.status_code == 200
-        data = response.json()
-
-        # Check top-level structure
-        assert "app" in data
-        assert "connection" in data
-        assert "patterns" in data
-        assert "auto_play" in data
-        assert "scheduled_pause" in data
-        assert "homing" in data
-        assert "led" in data
-        assert "mqtt" in data
-        assert "machine" in data
-
-        # Verify specific values
-        assert data["app"]["name"] == "Test Table"
-        assert data["connection"]["preferred_port"] == "__auto__"
-        assert data["patterns"]["clear_pattern_speed"] == 150
-        assert data["machine"]["detected_table_type"] == "dune_weaver"
-
-    @pytest.mark.asyncio
-    async def test_get_all_settings_effective_table_type(self, async_client, mock_state):
-        """Test that effective_table_type uses override when set."""
-        mock_state.app_name = "Test"
-        mock_state.custom_logo = None
-        mock_state.preferred_port = None
-        mock_state.clear_pattern_speed = None
-        mock_state.custom_clear_from_in = None
-        mock_state.custom_clear_from_out = None
-        mock_state.auto_play_enabled = False
-        mock_state.auto_play_playlist = None
-        mock_state.auto_play_run_mode = "single"
-        mock_state.auto_play_pause_time = 0
-        mock_state.auto_play_clear_pattern = None
-        mock_state.auto_play_shuffle = False
-        mock_state.scheduled_pause_enabled = False
-        mock_state.scheduled_pause_control_wled = False
-        mock_state.scheduled_pause_finish_pattern = False
-        mock_state.scheduled_pause_timezone = None
-        mock_state.scheduled_pause_time_slots = []
-        mock_state.homing = 0
-        mock_state.homing_user_override = False
-        mock_state.angular_homing_offset_degrees = 0.0
-        mock_state.auto_home_enabled = False
-        mock_state.auto_home_after_patterns = 10
-        mock_state.led_provider = "none"
-        mock_state.wled_ip = None
-        mock_state.dw_led_num_leds = 60
-        mock_state.dw_led_gpio_pin = 18
-        mock_state.dw_led_pixel_order = "GRB"
-        mock_state.dw_led_brightness = 50
-        mock_state.dw_led_speed = 128
-        mock_state.dw_led_intensity = 128
-        mock_state.dw_led_idle_effect = "solid"
-        mock_state.dw_led_playing_effect = "rainbow"
-        mock_state.dw_led_idle_timeout_enabled = False
-        mock_state.dw_led_idle_timeout_minutes = 30
-        mock_state.mqtt_enabled = False
-        mock_state.mqtt_broker = None
-        mock_state.mqtt_port = 1883
-        mock_state.mqtt_username = None
-        mock_state.mqtt_password = None
-        mock_state.mqtt_client_id = "dune_weaver"
-        mock_state.mqtt_discovery_prefix = "homeassistant"
-        mock_state.mqtt_device_id = "dune_weaver_01"
-        mock_state.mqtt_device_name = "Dune Weaver"
-        mock_state.table_type = "dune_weaver"
-        mock_state.table_type_override = "dune_weaver_mini"  # Override set
-        mock_state.gear_ratio = 6.25
-        mock_state.x_steps_per_mm = 256.0
-        mock_state.y_steps_per_mm = 180.0
-        mock_state.timezone = "UTC"
-
-        with patch("main.state", mock_state):
-            response = await async_client.get("/api/settings")
-
-        assert response.status_code == 200
-        data = response.json()
-
-        assert data["machine"]["detected_table_type"] == "dune_weaver"
-        assert data["machine"]["table_type_override"] == "dune_weaver_mini"
-        assert data["machine"]["effective_table_type"] == "dune_weaver_mini"
-
-
-class TestGetTableInfo:
-    """Tests for /api/table-info endpoint."""
-
-    @pytest.mark.asyncio
-    async def test_get_table_info(self, async_client, mock_state):
-        """Test get_table_info returns table identification info."""
-        mock_state.table_id = "table-123"
-        mock_state.table_name = "Living Room Table"
-
-        with patch("main.state", mock_state):
-            with patch("main.version_manager.get_current_version", return_value="1.0.0"):
-                response = await async_client.get("/api/table-info")
-
-        assert response.status_code == 200
-        data = response.json()
-        # API returns "id" and "name", not "table_id" and "table_name"
-        assert data["id"] == "table-123"
-        assert data["name"] == "Living Room Table"
-        assert data["version"] == "1.0.0"
-
-    @pytest.mark.asyncio
-    async def test_get_table_info_not_set(self, async_client, mock_state):
-        """Test get_table_info when not configured."""
-        mock_state.table_id = None
-        mock_state.table_name = None
-
-        with patch("main.state", mock_state):
-            with patch("main.version_manager.get_current_version", return_value="1.0.0"):
-                response = await async_client.get("/api/table-info")
-
-        assert response.status_code == 200
-        data = response.json()
-        assert data["id"] is None
-        assert data["name"] is None
+"""
+Unit tests for status and info API endpoints.
+
+Tests the following endpoints:
+- GET /serial_status
+- GET /list_serial_ports
+- GET /api/settings
+- GET /api/table-info
+"""
+import pytest
+from unittest.mock import patch, MagicMock, AsyncMock
+
+
+class TestSerialStatus:
+    """Tests for /serial_status endpoint."""
+
+    @pytest.mark.asyncio
+    async def test_serial_status_when_connected(self, async_client, mock_state):
+        """Test serial_status returns connected state and the board URL."""
+        mock_state.conn = MagicMock()
+        mock_state.conn.is_connected.return_value = True
+
+        with patch("main.state", mock_state), \
+             patch("main.connection_manager.board_url", return_value="http://192.168.68.160"):
+            response = await async_client.get("/serial_status")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert data["connected"] is True
+        assert data["port"] == "http://192.168.68.160"
+
+    @pytest.mark.asyncio
+    async def test_serial_status_when_disconnected(self, async_client, mock_state):
+        """Test serial_status still reports the configured board URL when disconnected."""
+        mock_state.conn = None
+
+        with patch("main.state", mock_state), \
+             patch("main.connection_manager.board_url", return_value="http://192.168.68.160"):
+            response = await async_client.get("/serial_status")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert data["connected"] is False
+        assert data["port"] == "http://192.168.68.160"
+
+    @pytest.mark.asyncio
+    async def test_serial_status_with_disconnected_conn(self, async_client, mock_state):
+        """Test serial_status when conn exists but is disconnected."""
+        mock_state.conn = MagicMock()
+        mock_state.conn.is_connected.return_value = False
+        mock_state.port = "/dev/ttyUSB0"
+        mock_state.preferred_port = "/dev/ttyUSB0"
+
+        with patch("main.state", mock_state):
+            response = await async_client.get("/serial_status")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert data["connected"] is False
+
+
+class TestListSerialPorts:
+    """Tests for /list_serial_ports endpoint."""
+
+    @pytest.mark.asyncio
+    async def test_list_serial_ports_returns_list(self, async_client):
+        """Test list_serial_ports returns a list of available ports."""
+        mock_ports = ["/dev/ttyUSB0", "/dev/ttyACM0"]
+
+        with patch("main.connection_manager.list_serial_ports", return_value=mock_ports):
+            response = await async_client.get("/list_serial_ports")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert isinstance(data, list)
+        assert "/dev/ttyUSB0" in data
+        assert "/dev/ttyACM0" in data
+
+    @pytest.mark.asyncio
+    async def test_list_serial_ports_empty(self, async_client):
+        """Test list_serial_ports returns empty list when no ports."""
+        with patch("main.connection_manager.list_serial_ports", return_value=[]):
+            response = await async_client.get("/list_serial_ports")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert data == []
+
+
+class TestGetAllSettings:
+    """Tests for /api/settings endpoint."""
+
+    @pytest.mark.asyncio
+    async def test_get_all_settings_returns_expected_structure(self, async_client, mock_state):
+        """Test get_all_settings returns the unified settings structure."""
+        mock_state.app_name = "Test Table"
+        mock_state.custom_logo = None
+        mock_state.clear_pattern_speed = 150
+        mock_state.custom_clear_from_in = None
+        mock_state.custom_clear_from_out = None
+        mock_state.scheduled_pause_enabled = False
+        mock_state.scheduled_pause_control_wled = False
+        mock_state.scheduled_pause_finish_pattern = False
+        mock_state.scheduled_pause_timezone = None
+        mock_state.scheduled_pause_time_slots = []
+        mock_state.homing = 0
+        mock_state.homing_user_override = False
+        mock_state.angular_homing_offset_degrees = 0.0
+        mock_state.auto_home_enabled = False
+        mock_state.auto_home_after_patterns = 10
+        mock_state.led_provider = "none"
+        mock_state.wled_ip = None
+        mock_state.dw_led_control_mode = "automated"
+        mock_state.dw_led_idle_timeout_enabled = False
+        mock_state.dw_led_idle_timeout_minutes = 30
+        mock_state.mqtt_enabled = False
+        mock_state.mqtt_broker = None
+        mock_state.mqtt_port = 1883
+        mock_state.mqtt_username = None
+        mock_state.mqtt_password = None
+        mock_state.mqtt_client_id = "dune_weaver"
+        mock_state.mqtt_discovery_prefix = "homeassistant"
+        mock_state.mqtt_device_id = "dune_weaver_01"
+        mock_state.mqtt_device_name = "Dune Weaver"
+        mock_state.timezone = "UTC"
+
+        with patch("main.state", mock_state):
+            response = await async_client.get("/api/settings")
+
+        assert response.status_code == 200
+        data = response.json()
+
+        # Check top-level structure (auto_play/connection are gone: auto-play
+        # lives on the board, serial ports no longer exist)
+        assert "app" in data
+        assert "patterns" in data
+        assert "scheduled_pause" in data
+        assert "homing" in data
+        assert "led" in data
+        assert "mqtt" in data
+        assert "machine" in data
+        assert "auto_play" not in data
+        assert "connection" not in data
+
+        # Verify specific values
+        assert data["app"]["name"] == "Test Table"
+        assert data["patterns"]["clear_pattern_speed"] == 150
+        assert data["machine"]["timezone"] == "UTC"
+
+
+class TestGetTableInfo:
+    """Tests for /api/table-info endpoint."""
+
+    @pytest.mark.asyncio
+    async def test_get_table_info(self, async_client, mock_state):
+        """Test get_table_info returns table identification info."""
+        mock_state.table_id = "table-123"
+        mock_state.table_name = "Living Room Table"
+
+        with patch("main.state", mock_state):
+            with patch("main.version_manager.get_current_version", return_value="1.0.0"):
+                response = await async_client.get("/api/table-info")
+
+        assert response.status_code == 200
+        data = response.json()
+        # API returns "id" and "name", not "table_id" and "table_name"
+        assert data["id"] == "table-123"
+        assert data["name"] == "Living Room Table"
+        assert data["version"] == "1.0.0"
+
+    @pytest.mark.asyncio
+    async def test_get_table_info_not_set(self, async_client, mock_state):
+        """Test get_table_info when not configured."""
+        mock_state.table_id = None
+        mock_state.table_name = None
+
+        with patch("main.state", mock_state):
+            with patch("main.version_manager.get_current_version", return_value="1.0.0"):
+                response = await async_client.get("/api/table-info")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert data["id"] is None
+        assert data["name"] is None

+ 96 - 0
tests/unit/test_board_led_controller.py

@@ -0,0 +1,96 @@
+"""
+Unit tests for the board (firmware) LED controller — focused on the 'ball'
+tracker: param clamping, key mapping, and status readback.
+"""
+from unittest.mock import patch
+
+from modules.led.board_led_controller import BoardLEDController
+from modules.core.state import state
+
+
+class FakeConn:
+    """Records /sand_led calls and serves a scriptable settings map."""
+
+    def __init__(self, settings=None):
+        self.led_calls = []
+        self._settings = settings or {}
+
+    def is_connected(self):
+        return True
+
+    def set_led(self, **keys):
+        self.led_calls.append(keys)
+        return "ok"
+
+    def get_settings(self):
+        return self._settings
+
+
+def _controller(conn):
+    # The controller reaches hardware through state.conn (imported lazily).
+    return BoardLEDController(), patch.object(state, "conn", conn)
+
+
+class TestSetBall:
+    def test_maps_and_forwards_keys(self):
+        conn = FakeConn()
+        c, ctx = _controller(conn)
+        with ctx:
+            c.set_ball(fgbright=200, bgbright=40, size=8, align=120,
+                       direction="ccw", bg="plasma", color="#FF0040", color2="000028")
+        assert len(conn.led_calls) == 1
+        sent = conn.led_calls[0]
+        assert sent == {
+            "fgbright": 200, "bgbright": 40, "size": 8, "align": 120,
+            "direction": "ccw", "bg": "plasma", "color": "FF0040", "color2": "000028",
+        }
+
+    def test_clamps_out_of_range(self):
+        conn = FakeConn()
+        c, ctx = _controller(conn)
+        with ctx:
+            c.set_ball(size=999, align=400, fgbright=-5, bgbright=500)
+        sent = conn.led_calls[0]
+        assert sent["size"] == 200      # 1..200
+        assert sent["align"] == 359     # 0..359
+        assert sent["fgbright"] == 0    # 0..255
+        assert sent["bgbright"] == 255  # 0..255
+
+    def test_ignores_invalid_direction(self):
+        conn = FakeConn()
+        c, ctx = _controller(conn)
+        with ctx:
+            c.set_ball(direction="sideways", size=5)
+        sent = conn.led_calls[0]
+        assert "direction" not in sent
+        assert sent["size"] == 5
+
+    def test_empty_params_no_call(self):
+        conn = FakeConn()
+        c, ctx = _controller(conn)
+        with ctx:
+            result = c.set_ball()
+        assert conn.led_calls == []
+        assert result == {"connected": True}
+
+
+class TestStatusBallReadback:
+    def test_check_status_exposes_ball(self):
+        conn = FakeConn(settings={
+            "LED/Effect": "ball",
+            "LED/Brightness": "128",
+            "LED/BallBright": "111",
+            "LED/BallBgBright": "222",
+            "LED/BallSize": "17",
+            "LED/BallBg": "fire",
+            "LED/Direction": "ccw",
+            "LED/Align": "88",
+        })
+        c, ctx = _controller(conn)
+        with ctx:
+            status = c.check_status()
+        assert status["connected"] is True
+        assert status["ball"] == {
+            "fgbright": 111, "bgbright": 222, "size": 17,
+            "bg": "fire", "direction": "ccw", "align": 88,
+        }

+ 182 - 0
tests/unit/test_board_settings.py

@@ -0,0 +1,182 @@
+"""
+Unit tests for board_settings — the host<->board settings sync layer.
+
+Covers:
+- Still Sands slot conversion (host dicts <-> $Sands/Slots spec string)
+- POSIX timezone derivation from TZif files
+- Adopting board $Sands/* values into host state
+- Autostart mapping to $Playlist/Autostart* commands
+- Playlist mirroring content
+"""
+import pytest
+from unittest.mock import MagicMock, patch
+
+from modules.core import board_settings
+from modules.core.state import state
+
+
+class TestSlotConversion:
+    """Host slot dicts <-> firmware '$Sands/Slots' spec string."""
+
+    def test_daily_slot_round_trip(self):
+        slots = [{"start_time": "22:00", "end_time": "06:00", "days": "daily", "custom_days": []}]
+        spec = board_settings.slots_to_board(slots)
+        assert spec == "22:00-06:00@daily"
+        assert board_settings.board_to_slots(spec) == slots
+
+    def test_weekdays_and_weekends(self):
+        slots = [
+            {"start_time": "21:00", "end_time": "08:00", "days": "weekdays", "custom_days": []},
+            {"start_time": "23:30", "end_time": "09:00", "days": "weekends", "custom_days": []},
+        ]
+        spec = board_settings.slots_to_board(slots)
+        assert spec == "21:00-08:00@weekdays,23:30-09:00@weekends"
+        assert board_settings.board_to_slots(spec) == slots
+
+    def test_custom_days_round_trip(self):
+        slots = [{
+            "start_time": "13:00", "end_time": "14:00", "days": "custom",
+            "custom_days": ["monday", "friday"],
+        }]
+        spec = board_settings.slots_to_board(slots)
+        assert spec == "13:00-14:00@mon+fri"
+        assert board_settings.board_to_slots(spec) == slots
+
+    def test_custom_days_sorted_in_firmware_order(self):
+        slots = [{
+            "start_time": "10:00", "end_time": "11:00", "days": "custom",
+            "custom_days": ["saturday", "sunday", "wednesday"],
+        }]
+        # bit 0 = Sunday in the firmware's day order
+        assert board_settings.slots_to_board(slots) == "10:00-11:00@sun+wed+sat"
+
+    def test_empty_custom_days_falls_back_to_daily(self):
+        slots = [{"start_time": "10:00", "end_time": "11:00", "days": "custom", "custom_days": []}]
+        assert board_settings.slots_to_board(slots) == "10:00-11:00@daily"
+
+    def test_parse_ignores_malformed_entries(self):
+        parsed = board_settings.board_to_slots("garbage,22:00-06:00@daily,also-bad@")
+        assert len(parsed) == 1
+        assert parsed[0]["start_time"] == "22:00"
+
+    def test_parse_bare_times_defaults_daily(self):
+        parsed = board_settings.board_to_slots("22:00-06:00")
+        assert parsed == [{"start_time": "22:00", "end_time": "06:00", "days": "daily", "custom_days": []}]
+
+    def test_empty_specs(self):
+        assert board_settings.slots_to_board([]) == ""
+        assert board_settings.board_to_slots("") == []
+
+
+class TestPosixTz:
+    def test_known_iana_zone(self):
+        # Skip if the zoneinfo database isn't at the standard path.
+        import os
+        if not os.path.exists("/usr/share/zoneinfo/America/Toronto"):
+            pytest.skip("no system zoneinfo db")
+        tz = board_settings.posix_tz("America/Toronto")
+        assert tz is not None and tz.startswith("EST5EDT")
+
+    def test_unknown_zone_returns_none(self):
+        assert board_settings.posix_tz("Not/AZone") is None
+
+
+class TestAdoptStillSands:
+    @pytest.fixture(autouse=True)
+    def _snapshot_state(self):
+        saved = (
+            state.scheduled_pause_enabled,
+            state.scheduled_pause_time_slots,
+            state.scheduled_pause_control_wled,
+            state.scheduled_pause_finish_pattern,
+        )
+        yield
+        (
+            state.scheduled_pause_enabled,
+            state.scheduled_pause_time_slots,
+            state.scheduled_pause_control_wled,
+            state.scheduled_pause_finish_pattern,
+        ) = saved
+
+    def test_adopts_board_values(self):
+        state.scheduled_pause_enabled = False
+        state.scheduled_pause_time_slots = []
+        state.scheduled_pause_control_wled = False
+        state.scheduled_pause_finish_pattern = False
+        with patch.object(state, "save") as save:
+            changed = board_settings.adopt_still_sands({
+                "Sands/Enabled": "ON",
+                "Sands/Slots": "21:00-08:00@daily",
+                "Sands/FinishPattern": "OFF",
+                "Sands/LedOff": "ON",
+            })
+        assert changed is True
+        save.assert_called_once()
+        assert state.scheduled_pause_enabled is True
+        assert state.scheduled_pause_finish_pattern is False
+        assert state.scheduled_pause_control_wled is True
+        assert state.scheduled_pause_time_slots[0]["start_time"] == "21:00"
+
+    def test_no_change_no_save(self):
+        state.scheduled_pause_enabled = True
+        state.scheduled_pause_finish_pattern = True
+        state.scheduled_pause_control_wled = False
+        state.scheduled_pause_time_slots = board_settings.board_to_slots("21:00-08:00@daily")
+        with patch.object(state, "save") as save:
+            changed = board_settings.adopt_still_sands({
+                "Sands/Enabled": "ON",
+                "Sands/Slots": "21:00-08:00@daily",
+                "Sands/FinishPattern": "ON",
+                "Sands/LedOff": "OFF",
+            })
+        assert changed is False
+        save.assert_not_called()
+
+
+class TestApplyAutostart:
+    def test_maps_fields_to_board_settings(self):
+        conn = MagicMock()
+        board_settings.apply_autostart({
+            "playlist": "evening",
+            "run_mode": "loop",
+            "shuffle": True,
+            "pause_seconds": 90,
+            "pause_from_start": False,
+            "clear_pattern": "adaptive",
+        }, conn=conn)
+        written = {call.args[0]: call.args[1] for call in conn.set_setting.call_args_list}
+        assert written == {
+            "Playlist/Autostart": "evening",
+            "Playlist/AutostartMode": "loop",
+            "Playlist/AutostartShuffle": "ON",
+            "Playlist/AutostartPause": 90,
+            "Playlist/AutostartPauseFromStart": "OFF",
+            "Playlist/AutostartClear": "adaptive",
+        }
+
+    def test_empty_playlist_disables(self):
+        conn = MagicMock()
+        board_settings.apply_autostart({"playlist": ""}, conn=conn)
+        conn.set_setting.assert_called_once_with("Playlist/Autostart", "")
+
+    def test_invalid_clear_mode_coerced_to_none(self):
+        conn = MagicMock()
+        board_settings.apply_autostart({"clear_pattern": "clear_from_in"}, conn=conn)
+        conn.set_setting.assert_called_once_with("Playlist/AutostartClear", "none")
+
+
+class TestPlaylistMirroring:
+    def test_playlist_content_uses_sd_paths(self):
+        content = board_settings._playlist_sd_content(
+            ["./patterns/star.thr", "patterns/sub/wave.thr"]
+        )
+        assert content == "/patterns/star.thr\n/patterns/sub/wave.thr\n"
+
+    def test_mirror_uploads_txt(self):
+        conn = MagicMock()
+        board_settings.mirror_playlist("evening", ["./patterns/star.thr"], conn=conn)
+        conn.upload_file.assert_called_once()
+        sd_path, data, directory = conn.upload_file.call_args.args
+        assert sd_path == "/playlists/evening.txt"
+        assert data == b"/patterns/star.thr\n"
+        assert directory == "/playlists"

+ 342 - 0
tests/unit/test_execution.py

@@ -0,0 +1,342 @@
+"""
+Unit tests for modules/core/execution.py — the firmware-delegation layer.
+
+Covers:
+- translate_status: raw /sand_status fixtures -> the /ws/status contract
+- BoardObserver edge detection: history logging, hold accounting,
+  clear-speed shim, run-end reset, reboot guard
+- start_playlist call order (stop-first, NVS params, mirror, run)
+- skip routing (playlist vs single pattern)
+"""
+import json
+import pytest
+from unittest.mock import MagicMock, patch
+
+from modules.core import execution
+from modules.core.execution import BoardObserver, RunContext, map_clear_mode, _from_sd_path, translate_status
+from modules.core.state import state
+
+
+class FakeConn:
+    """Records calls; scriptable status."""
+
+    def __init__(self, statuses=None):
+        self.calls = []
+        self.statuses = list(statuses or [])
+        self._stopped = False
+
+    def is_connected(self):
+        return True
+
+    def get_status(self):
+        self.calls.append(("get_status",))
+        if self._stopped:
+            return {"state": "Idle", "running": False, "playlist": {"active": False}}
+        if self.statuses:
+            return self.statuses.pop(0)
+        return {"state": "Idle", "running": False, "playlist": {"active": False}}
+
+    def stop(self):
+        self.calls.append(("stop",))
+        self._stopped = True
+
+    def skip(self):
+        self.calls.append(("skip",))
+
+    def pause(self):
+        self.calls.append(("pause",))
+
+    def resume(self):
+        self.calls.append(("resume",))
+
+    def set_setting(self, key, value):
+        self.calls.append(("set_setting", key, str(value)))
+
+    def set_feed(self, mm=None, **kw):
+        self.calls.append(("set_feed", mm))
+
+    def run_command(self, plain):
+        self.calls.append(("run_command", plain))
+        return "ok"
+
+    def run_pattern(self, sd_path, clear=None):
+        self.calls.append(("run_pattern", sd_path, clear))
+
+    def file_exists(self, sd_path):
+        return True
+
+    def upload_file(self, sd_path, data, directory):
+        self.calls.append(("upload_file", sd_path))
+        return {}
+
+
+@pytest.fixture(autouse=True)
+def clean_state():
+    saved = (state.conn, state.current_playing_file, state.current_playlist,
+             state.current_playlist_name, state.speed, state.clear_pattern_speed,
+             execution.current_run)
+    state.conn = None
+    execution.current_run = None
+    with patch.object(state, "save"):
+        yield
+    (state.conn, state.current_playing_file, state.current_playlist,
+     state.current_playlist_name, state.speed, state.clear_pattern_speed,
+     execution.current_run) = saved
+
+
+def _status(**over):
+    base = {
+        "state": "Run", "running": True, "file": "/patterns/star.thr",
+        "progress": 0.5, "theta": 1.0, "rho": 0.5, "feed": 500, "uptime": 1000,
+        "fw": "v0.1.3",
+        "playlist": {"active": False, "index": 0, "total": 0, "name": "",
+                     "clearing": False, "quiet": False,
+                     "pause_remaining": -1, "pause_total": -1},
+    }
+    pl_over = over.pop("playlist", {})
+    base.update(over)
+    base["playlist"].update(pl_over)
+    return base
+
+
+class TestMapping:
+    def test_clear_mode_mapping(self):
+        assert map_clear_mode("clear_from_in") == "in"
+        assert map_clear_mode("clear_from_out") == "out"
+        assert map_clear_mode("clear_sideway") == "sideway"
+        assert map_clear_mode("adaptive") == "adaptive"
+        assert map_clear_mode(None) == "none"
+        assert map_clear_mode("bogus") == "none"
+
+    def test_sd_path_mapping(self):
+        assert _from_sd_path("/patterns/a/b.thr") == "./patterns/a/b.thr"
+        assert _from_sd_path("/sd/patterns/x.thr") == "./patterns/x.thr"
+        assert _from_sd_path("") is None
+
+
+class TestTranslateStatus:
+    def test_offline(self):
+        out = translate_status(None, BoardObserver())
+        assert out["connection_status"] is False
+        assert out["is_running"] is False
+        assert out["playlist"] is None
+        assert out["progress"] is None
+
+    def test_running_pattern(self):
+        obs = BoardObserver()
+        obs.file_started_at = 90.0
+        state.conn = FakeConn()
+        out = translate_status(_status(progress=0.425), obs, now=100.0)
+        assert out["is_running"] is True
+        assert out["current_file"] == "./patterns/star.thr"
+        assert out["progress"]["percentage"] == 42.5
+        assert out["progress"]["elapsed_time"] == pytest.approx(10.0)
+        # remaining = elapsed/fraction - elapsed
+        assert out["progress"]["remaining_time"] == pytest.approx(10 / 0.425 - 10)
+        assert out["is_paused"] is False
+
+    def test_hold_is_paused(self):
+        state.conn = FakeConn()
+        out = translate_status(_status(state="Hold"), BoardObserver())
+        assert out["is_paused"] is True
+
+    def test_hold_substate_is_paused(self):
+        # GRBL reports a substate suffix ("Hold:0") that must still read as paused.
+        state.conn = FakeConn()
+        out = translate_status(_status(state="Hold:0"), BoardObserver())
+        assert out["is_paused"] is True
+
+    def test_playlist_pause_countdown(self):
+        state.conn = FakeConn()
+        execution.current_run = RunContext(kind="playlist", playlist_name="fav",
+                                           run_mode="indefinite")
+        state.current_playlist = ["./patterns/a.thr", "./patterns/b.thr"]
+        out = translate_status(_status(
+            running=False, state="Idle", file="",
+            playlist={"active": True, "index": 0, "total": 2, "name": "fav",
+                      "pause_remaining": 30, "pause_total": 60},
+        ), BoardObserver())
+        assert out["is_running"] is False
+        assert out["pause_time_remaining"] == 30
+        assert out["original_pause_time"] == 60
+        assert out["playlist"]["name"] == "fav"
+        assert out["playlist"]["mode"] == "indefinite"
+        assert out["playlist"]["total_files"] == 2
+        assert out["playlist"]["next_file"] == "./patterns/b.thr"
+        assert out["playlist"]["shuffled"] is False
+
+    def test_playlist_clearing_next_file(self):
+        state.conn = FakeConn()
+        execution.current_run = RunContext(kind="playlist", playlist_name="fav")
+        state.current_playlist = ["./patterns/a.thr", "./patterns/b.thr"]
+        out = translate_status(_status(
+            playlist={"active": True, "index": 1, "total": 2, "name": "fav",
+                      "clearing": True},
+        ), BoardObserver())
+        # While clearing, "next" is the pattern the clear precedes.
+        assert out["playlist"]["next_file"] == "./patterns/b.thr"
+        assert out["is_clearing"] is True
+
+    def test_shuffled_playlist_hides_next(self):
+        state.conn = FakeConn()
+        execution.current_run = RunContext(kind="playlist", playlist_name="fav",
+                                           shuffle=True)
+        state.current_playlist = ["./patterns/a.thr", "./patterns/b.thr"]
+        out = translate_status(_status(
+            playlist={"active": True, "index": 0, "total": 2, "name": "fav"},
+        ), BoardObserver())
+        assert out["playlist"]["shuffled"] is True
+        assert out["playlist"]["next_file"] is None
+        assert out["playlist"]["files"]  # still served read-only
+
+
+class TestObserverEdges:
+    @pytest.fixture
+    def log_file(self, tmp_path):
+        path = tmp_path / "execution_times.jsonl"
+        with patch("modules.core.pattern_manager.EXECUTION_LOG_FILE", str(path)):
+            yield path
+
+    async def test_file_transition_logs_history(self, log_file):
+        state.conn = FakeConn()
+        obs = BoardObserver()
+        await obs.process(_status(file="/patterns/a.thr", progress=0.2), now=0.0)
+        await obs.process(_status(file="/patterns/a.thr", progress=0.99), now=100.0)
+        await obs.process(_status(file="/patterns/b.thr", progress=0.0), now=110.0)
+        rows = [json.loads(l) for l in log_file.read_text().splitlines()]
+        assert len(rows) == 1
+        assert rows[0]["pattern_name"] == "a.thr"
+        assert rows[0]["completed"] is True
+        assert state.current_playing_file == "./patterns/b.thr"
+
+    async def test_aborted_run_not_completed(self, log_file):
+        state.conn = FakeConn()
+        obs = BoardObserver()
+        await obs.process(_status(file="/patterns/a.thr", progress=0.3), now=0.0)
+        await obs.process(_status(running=False, state="Idle", file=""), now=50.0)
+        rows = [json.loads(l) for l in log_file.read_text().splitlines()]
+        assert len(rows) == 1
+        assert rows[0]["completed"] is False
+
+    async def test_hold_time_excluded(self, log_file):
+        state.conn = FakeConn()
+        obs = BoardObserver()
+        await obs.process(_status(file="/patterns/a.thr"), now=0.0)
+        await obs.process(_status(file="/patterns/a.thr", state="Hold"), now=10.0)
+        await obs.process(_status(file="/patterns/a.thr", state="Run", progress=0.99), now=40.0)
+        await obs.process(_status(running=False, state="Idle", file=""), now=50.0)
+        rows = [json.loads(l) for l in log_file.read_text().splitlines()]
+        # 50s wall clock minus 30s hold = 20s
+        assert rows[0]["actual_time_seconds"] == pytest.approx(20.0, abs=0.5)
+
+    async def test_clear_files_not_logged(self, log_file):
+        state.conn = FakeConn()
+        obs = BoardObserver()
+        await obs.process(_status(file="/patterns/clear_from_in.thr",
+                                  playlist={"active": True, "clearing": True, "total": 2}), now=0.0)
+        await obs.process(_status(file="/patterns/a.thr",
+                                  playlist={"active": True, "total": 2}), now=30.0)
+        assert not log_file.exists()
+
+    async def test_clear_speed_shim(self, log_file):
+        conn = FakeConn()
+        state.conn = conn
+        state.speed = 400
+        state.clear_pattern_speed = 150
+        obs = BoardObserver()
+        await obs.process(_status(file="/patterns/clear_from_in.thr",
+                                  playlist={"active": True, "clearing": True, "total": 1}), now=0.0)
+        assert ("set_feed", 150) in conn.calls
+        conn.calls.clear()
+        await obs.process(_status(file="/patterns/a.thr",
+                                  playlist={"active": True, "total": 1}), now=30.0)
+        assert ("set_feed", 400) in conn.calls
+
+    async def test_run_end_resets_state(self, log_file):
+        state.conn = FakeConn()
+        execution.current_run = RunContext(kind="playlist", playlist_name="fav")
+        state.current_playlist = ["./patterns/a.thr"]
+        obs = BoardObserver()
+        await obs.process(_status(file="/patterns/a.thr",
+                                  playlist={"active": True, "total": 1}), now=0.0)
+        await obs.process(_status(running=False, state="Idle", file="",
+                                  playlist={"active": False}), now=60.0)
+        assert execution.current_run is None
+        assert state.current_playlist is None
+        assert state.current_playing_file is None
+
+    async def test_reboot_guard(self, log_file):
+        state.conn = FakeConn()
+        obs = BoardObserver()
+        await obs.process(_status(file="/patterns/a.thr", uptime=5000), now=0.0)
+        await obs.process(_status(running=False, state="Idle", file="", uptime=10), now=10.0)
+        # Reboot detected: context reset, no completion logged
+        assert not log_file.exists()
+        assert obs.prev is not None  # keeps observing after reset
+
+
+class TestCommands:
+    async def test_start_playlist_call_order(self):
+        conn = FakeConn(statuses=[{"state": "Run", "running": True, "playlist": {"active": True}}])
+        state.conn = conn
+        state.speed = 300
+        with patch("modules.core.playlist_manager.get_playlist",
+                   return_value={"name": "fav", "files": ["patterns/a.thr", "patterns/b.thr"]}), \
+             patch("modules.core.pattern_manager._ensure_on_board"):
+            await execution.start_playlist("fav", run_mode="indefinite", pause_time=30,
+                                           clear_pattern="clear_from_in", shuffle=True)
+
+        names = [c[0] for c in conn.calls]
+        # stop-first (board was running), then NVS params, mirror, run
+        assert names.index("stop") < names.index("set_setting")
+        settings = [(c[1], c[2]) for c in conn.calls if c[0] == "set_setting"]
+        assert ("Playlist/Mode", "loop") in settings
+        assert ("Playlist/Shuffle", "ON") in settings
+        assert ("Playlist/PauseTime", "30") in settings
+        assert ("Playlist/ClearPattern", "in") in settings
+        assert ("upload_file", "/playlists/fav.txt") in conn.calls
+        assert ("run_command", "$Playlist/Run=fav") in conn.calls
+        assert conn.calls.index(("upload_file", "/playlists/fav.txt")) < \
+               conn.calls.index(("run_command", "$Playlist/Run=fav"))
+        assert execution.current_run.kind == "playlist"
+        assert state.current_playlist_name == "fav"
+
+    async def test_start_playlist_empty_raises(self):
+        state.conn = FakeConn()
+        with patch("modules.core.playlist_manager.get_playlist",
+                   return_value={"name": "e", "files": []}):
+            with pytest.raises(execution.ExecutionError):
+                await execution.start_playlist("e")
+
+    async def test_skip_routes_playlist_vs_single(self):
+        conn = FakeConn()
+        state.conn = conn
+        execution.observer.last_raw = _status(playlist={"active": True, "total": 2})
+        assert await execution.skip() is True
+        assert ("skip",) in conn.calls
+        conn.calls.clear()
+        execution.observer.last_raw = _status()  # single pattern running
+        assert await execution.skip() is True
+        assert ("stop",) in conn.calls
+        conn.calls.clear()
+        execution.observer.last_raw = _status(running=False, state="Idle")
+        assert await execution.skip() is False
+
+    async def test_run_pattern_uses_sand_run(self):
+        conn = FakeConn()
+        state.conn = conn
+        state.speed = 250
+        with patch("modules.core.pattern_manager._ensure_on_board"):
+            await execution.run_pattern("./patterns/star.thr", "adaptive")
+        assert ("run_pattern", "/patterns/star.thr", "adaptive") in conn.calls
+        assert execution.current_run.kind == "pattern"
+
+    async def test_force_stop_resets_even_on_error(self):
+        conn = FakeConn()
+        conn.stop = MagicMock(side_effect=RuntimeError("boom"))
+        state.conn = conn
+        state.current_playing_file = "./patterns/a.thr"
+        execution.current_run = RunContext(kind="pattern")
+        assert await execution.stop(force=True) is True
+        assert execution.current_run is None
+        assert state.current_playing_file is None

+ 245 - 352
tests/unit/test_pattern_manager.py

@@ -1,352 +1,245 @@
-"""
-Unit tests for pattern_manager parsing logic.
-
-Tests the core pattern file operations:
-- Parsing theta-rho files
-- Handling comments and empty lines
-- Error handling for invalid files
-- Listing pattern files
-"""
-import os
-import pytest
-from unittest.mock import patch, MagicMock
-
-
-class TestParseTheTaRhoFile:
-    """Tests for parse_theta_rho_file function."""
-
-    def test_parse_theta_rho_file_valid(self, tmp_path):
-        """Test parsing a valid theta-rho file."""
-        # Create test file
-        test_file = tmp_path / "valid.thr"
-        test_file.write_text("0.0 0.5\n1.57 0.8\n3.14 0.3\n")
-
-        from modules.core.pattern_manager import parse_theta_rho_file
-
-        coordinates = parse_theta_rho_file(str(test_file))
-
-        assert len(coordinates) == 3
-        assert coordinates[0] == (0.0, 0.5)
-        assert coordinates[1] == (1.57, 0.8)
-        assert coordinates[2] == (3.14, 0.3)
-
-    def test_parse_theta_rho_file_with_comments(self, tmp_path):
-        """Test parsing handles # comments correctly."""
-        test_file = tmp_path / "commented.thr"
-        test_file.write_text("""# This is a header comment
-0.0 0.5
-# Another comment in the middle
-1.0 0.6
-# Trailing comment
-""")
-
-        from modules.core.pattern_manager import parse_theta_rho_file
-
-        coordinates = parse_theta_rho_file(str(test_file))
-
-        assert len(coordinates) == 2
-        assert coordinates[0] == (0.0, 0.5)
-        assert coordinates[1] == (1.0, 0.6)
-
-    def test_parse_theta_rho_file_empty_lines(self, tmp_path):
-        """Test parsing handles empty lines correctly."""
-        test_file = tmp_path / "spaced.thr"
-        test_file.write_text("""0.0 0.5
-
-1.0 0.6
-
-2.0 0.7
-
-""")
-
-        from modules.core.pattern_manager import parse_theta_rho_file
-
-        coordinates = parse_theta_rho_file(str(test_file))
-
-        assert len(coordinates) == 3
-        assert coordinates[0] == (0.0, 0.5)
-        assert coordinates[1] == (1.0, 0.6)
-        assert coordinates[2] == (2.0, 0.7)
-
-    def test_parse_theta_rho_file_not_found(self, tmp_path):
-        """Test parsing a non-existent file returns empty list."""
-        from modules.core.pattern_manager import parse_theta_rho_file
-
-        coordinates = parse_theta_rho_file(str(tmp_path / "nonexistent.thr"))
-
-        assert coordinates == []
-
-    def test_parse_theta_rho_file_invalid_lines(self, tmp_path):
-        """Test parsing skips invalid lines (non-numeric values)."""
-        test_file = tmp_path / "invalid.thr"
-        test_file.write_text("""0.0 0.5
-invalid line
-1.0 0.6
-not a number here
-2.0 0.7
-""")
-
-        from modules.core.pattern_manager import parse_theta_rho_file
-
-        coordinates = parse_theta_rho_file(str(test_file))
-
-        # Should only get the valid lines
-        assert len(coordinates) == 3
-        assert coordinates[0] == (0.0, 0.5)
-        assert coordinates[1] == (1.0, 0.6)
-        assert coordinates[2] == (2.0, 0.7)
-
-    def test_parse_theta_rho_file_whitespace_handling(self, tmp_path):
-        """Test parsing handles various whitespace correctly."""
-        test_file = tmp_path / "whitespace.thr"
-        test_file.write_text("""  0.0 0.5
-	1.0 0.6
-0.0    0.5
-""")
-
-        from modules.core.pattern_manager import parse_theta_rho_file
-
-        coordinates = parse_theta_rho_file(str(test_file))
-
-        assert len(coordinates) == 3
-
-    def test_parse_theta_rho_file_scientific_notation(self, tmp_path):
-        """Test parsing handles scientific notation."""
-        test_file = tmp_path / "scientific.thr"
-        test_file.write_text("""1.5e-3 0.5
-3.14159 1.0e0
-""")
-
-        from modules.core.pattern_manager import parse_theta_rho_file
-
-        coordinates = parse_theta_rho_file(str(test_file))
-
-        assert len(coordinates) == 2
-        assert coordinates[0][0] == pytest.approx(0.0015)
-        assert coordinates[1][1] == pytest.approx(1.0)
-
-    def test_parse_theta_rho_file_negative_values(self, tmp_path):
-        """Test parsing handles negative values."""
-        test_file = tmp_path / "negative.thr"
-        test_file.write_text("""-3.14 0.5
-0.0 -0.5
--1.0 -0.3
-""")
-
-        from modules.core.pattern_manager import parse_theta_rho_file
-
-        coordinates = parse_theta_rho_file(str(test_file))
-
-        assert len(coordinates) == 3
-        assert coordinates[0] == (-3.14, 0.5)
-        assert coordinates[1] == (0.0, -0.5)
-        assert coordinates[2] == (-1.0, -0.3)
-
-    def test_parse_theta_rho_file_only_comments(self, tmp_path):
-        """Test parsing a file with only comments returns empty list."""
-        test_file = tmp_path / "comments_only.thr"
-        test_file.write_text("""# This file only has comments
-# No actual coordinates
-# Just documentation
-""")
-
-        from modules.core.pattern_manager import parse_theta_rho_file
-
-        coordinates = parse_theta_rho_file(str(test_file))
-
-        assert coordinates == []
-
-    def test_parse_theta_rho_file_empty_file(self, tmp_path):
-        """Test parsing an empty file returns empty list."""
-        test_file = tmp_path / "empty.thr"
-        test_file.write_text("")
-
-        from modules.core.pattern_manager import parse_theta_rho_file
-
-        coordinates = parse_theta_rho_file(str(test_file))
-
-        assert coordinates == []
-
-
-class TestListThetaRhoFiles:
-    """Tests for list_theta_rho_files function."""
-
-    def test_list_theta_rho_files_basic(self, tmp_path):
-        """Test listing pattern files in directory."""
-        # Create test pattern files
-        patterns_dir = tmp_path / "patterns"
-        patterns_dir.mkdir()
-        (patterns_dir / "circle.thr").write_text("0 0.5")
-        (patterns_dir / "spiral.thr").write_text("0 0.5")
-        (patterns_dir / "readme.txt").write_text("not a pattern")
-
-        with patch("modules.core.pattern_manager.THETA_RHO_DIR", str(patterns_dir)):
-            from modules.core.pattern_manager import list_theta_rho_files
-
-            files = list_theta_rho_files()
-
-        # Should only list .thr files
-        assert len(files) == 2
-        assert "circle.thr" in files
-        assert "spiral.thr" in files
-
-    def test_list_theta_rho_files_subdirectories(self, tmp_path):
-        """Test listing pattern files in subdirectories."""
-        patterns_dir = tmp_path / "patterns"
-        patterns_dir.mkdir()
-
-        # Create subdirectory with patterns
-        subdir = patterns_dir / "custom"
-        subdir.mkdir()
-        (subdir / "custom_pattern.thr").write_text("0 0.5")
-        (patterns_dir / "root_pattern.thr").write_text("0 0.5")
-
-        with patch("modules.core.pattern_manager.THETA_RHO_DIR", str(patterns_dir)):
-            from modules.core.pattern_manager import list_theta_rho_files
-
-            files = list_theta_rho_files()
-
-        assert len(files) == 2
-        assert "root_pattern.thr" in files
-        # Subdirectory patterns should include relative path
-        assert "custom/custom_pattern.thr" in files
-
-    def test_list_theta_rho_files_skips_cached_images(self, tmp_path):
-        """Test that cached_images directories are skipped."""
-        patterns_dir = tmp_path / "patterns"
-        patterns_dir.mkdir()
-
-        # Create cached_images directory with files
-        cache_dir = patterns_dir / "cached_images"
-        cache_dir.mkdir()
-        (cache_dir / "preview.thr").write_text("should be skipped")
-
-        (patterns_dir / "real_pattern.thr").write_text("0 0.5")
-
-        with patch("modules.core.pattern_manager.THETA_RHO_DIR", str(patterns_dir)):
-            from modules.core.pattern_manager import list_theta_rho_files
-
-            files = list_theta_rho_files()
-
-        # Should only list the real pattern, not cached files
-        assert len(files) == 1
-        assert "real_pattern.thr" in files
-
-    def test_list_theta_rho_files_empty_directory(self, tmp_path):
-        """Test listing from empty directory returns empty list."""
-        patterns_dir = tmp_path / "patterns"
-        patterns_dir.mkdir()
-
-        with patch("modules.core.pattern_manager.THETA_RHO_DIR", str(patterns_dir)):
-            from modules.core.pattern_manager import list_theta_rho_files
-
-            files = list_theta_rho_files()
-
-        assert files == []
-
-
-class TestGetStatus:
-    """Tests for get_status function."""
-
-    def test_get_status_idle(self, mock_state):
-        """Test get_status returns expected fields when idle."""
-        with patch("modules.core.pattern_manager.state", mock_state):
-            from modules.core.pattern_manager import get_status
-
-            status = get_status()
-
-        assert "current_file" in status
-        assert "is_paused" in status
-        assert "is_running" in status
-        assert "is_homing" in status
-        assert "progress" in status
-        assert "playlist" in status
-        assert "speed" in status
-        assert "connection_status" in status
-        assert status["is_running"] is False
-        assert status["current_file"] is None
-
-    def test_get_status_running_pattern(self, mock_state):
-        """Test get_status reflects running pattern."""
-        mock_state.current_playing_file = "test_pattern.thr"
-        mock_state.stop_requested = False
-        mock_state.execution_progress = (50, 100, 30.5, 60.0)
-
-        with patch("modules.core.pattern_manager.state", mock_state):
-            from modules.core.pattern_manager import get_status
-
-            status = get_status()
-
-        assert status["is_running"] is True
-        assert status["current_file"] == "test_pattern.thr"
-        assert status["progress"] is not None
-        assert status["progress"]["current"] == 50
-        assert status["progress"]["total"] == 100
-        assert status["progress"]["percentage"] == 50.0
-
-    def test_get_status_paused(self, mock_state):
-        """Test get_status reflects paused state."""
-        mock_state.pause_requested = True
-
-        with patch("modules.core.pattern_manager.state", mock_state):
-            with patch("modules.core.pattern_manager.is_in_scheduled_pause_period", return_value=False):
-                from modules.core.pattern_manager import get_status
-
-                status = get_status()
-
-        assert status["is_paused"] is True
-        assert status["manual_pause"] is True
-
-    def test_get_status_with_playlist(self, mock_state):
-        """Test get_status includes playlist info when running."""
-        mock_state.current_playlist = ["a.thr", "b.thr", "c.thr"]
-        mock_state.current_playlist_name = "test_playlist"
-        mock_state.current_playlist_index = 1
-        mock_state.playlist_mode = "indefinite"
-
-        with patch("modules.core.pattern_manager.state", mock_state):
-            from modules.core.pattern_manager import get_status
-
-            status = get_status()
-
-        assert status["playlist"] is not None
-        assert status["playlist"]["current_index"] == 1
-        assert status["playlist"]["total_files"] == 3
-        assert status["playlist"]["mode"] == "indefinite"
-        assert status["playlist"]["name"] == "test_playlist"
-
-
-class TestIsClearPattern:
-    """Tests for is_clear_pattern function."""
-
-    def test_is_clear_pattern_matches_standard(self):
-        """Test identifying standard clear patterns."""
-        from modules.core.pattern_manager import is_clear_pattern
-
-        assert is_clear_pattern("./patterns/clear_from_out.thr") is True
-        assert is_clear_pattern("./patterns/clear_from_in.thr") is True
-        assert is_clear_pattern("./patterns/clear_sideway.thr") is True
-
-    def test_is_clear_pattern_matches_mini(self):
-        """Test identifying mini table clear patterns."""
-        from modules.core.pattern_manager import is_clear_pattern
-
-        assert is_clear_pattern("./patterns/clear_from_out_mini.thr") is True
-        assert is_clear_pattern("./patterns/clear_from_in_mini.thr") is True
-        assert is_clear_pattern("./patterns/clear_sideway_mini.thr") is True
-
-    def test_is_clear_pattern_matches_pro(self):
-        """Test identifying pro table clear patterns."""
-        from modules.core.pattern_manager import is_clear_pattern
-
-        assert is_clear_pattern("./patterns/clear_from_out_pro.thr") is True
-        assert is_clear_pattern("./patterns/clear_from_in_pro.thr") is True
-        assert is_clear_pattern("./patterns/clear_sideway_pro.thr") is True
-
-    def test_is_clear_pattern_rejects_regular_patterns(self):
-        """Test that regular patterns are not identified as clear patterns."""
-        from modules.core.pattern_manager import is_clear_pattern
-
-        assert is_clear_pattern("./patterns/circle.thr") is False
-        assert is_clear_pattern("./patterns/spiral.thr") is False
-        assert is_clear_pattern("./patterns/custom/my_pattern.thr") is False
+"""
+Unit tests for pattern_manager parsing logic.
+
+Tests the core pattern file operations:
+- Parsing theta-rho files
+- Handling comments and empty lines
+- Error handling for invalid files
+- Listing pattern files
+"""
+import os
+import pytest
+from unittest.mock import patch, MagicMock
+
+
+class TestParseTheTaRhoFile:
+    """Tests for parse_theta_rho_file function."""
+
+    def test_parse_theta_rho_file_valid(self, tmp_path):
+        """Test parsing a valid theta-rho file."""
+        # Create test file
+        test_file = tmp_path / "valid.thr"
+        test_file.write_text("0.0 0.5\n1.57 0.8\n3.14 0.3\n")
+
+        from modules.core.pattern_manager import parse_theta_rho_file
+
+        coordinates = parse_theta_rho_file(str(test_file))
+
+        assert len(coordinates) == 3
+        assert coordinates[0] == (0.0, 0.5)
+        assert coordinates[1] == (1.57, 0.8)
+        assert coordinates[2] == (3.14, 0.3)
+
+    def test_parse_theta_rho_file_with_comments(self, tmp_path):
+        """Test parsing handles # comments correctly."""
+        test_file = tmp_path / "commented.thr"
+        test_file.write_text("""# This is a header comment
+0.0 0.5
+# Another comment in the middle
+1.0 0.6
+# Trailing comment
+""")
+
+        from modules.core.pattern_manager import parse_theta_rho_file
+
+        coordinates = parse_theta_rho_file(str(test_file))
+
+        assert len(coordinates) == 2
+        assert coordinates[0] == (0.0, 0.5)
+        assert coordinates[1] == (1.0, 0.6)
+
+    def test_parse_theta_rho_file_empty_lines(self, tmp_path):
+        """Test parsing handles empty lines correctly."""
+        test_file = tmp_path / "spaced.thr"
+        test_file.write_text("""0.0 0.5
+
+1.0 0.6
+
+2.0 0.7
+
+""")
+
+        from modules.core.pattern_manager import parse_theta_rho_file
+
+        coordinates = parse_theta_rho_file(str(test_file))
+
+        assert len(coordinates) == 3
+        assert coordinates[0] == (0.0, 0.5)
+        assert coordinates[1] == (1.0, 0.6)
+        assert coordinates[2] == (2.0, 0.7)
+
+    def test_parse_theta_rho_file_not_found(self, tmp_path):
+        """Test parsing a non-existent file returns empty list."""
+        from modules.core.pattern_manager import parse_theta_rho_file
+
+        coordinates = parse_theta_rho_file(str(tmp_path / "nonexistent.thr"))
+
+        assert coordinates == []
+
+    def test_parse_theta_rho_file_invalid_lines(self, tmp_path):
+        """Test parsing skips invalid lines (non-numeric values)."""
+        test_file = tmp_path / "invalid.thr"
+        test_file.write_text("""0.0 0.5
+invalid line
+1.0 0.6
+not a number here
+2.0 0.7
+""")
+
+        from modules.core.pattern_manager import parse_theta_rho_file
+
+        coordinates = parse_theta_rho_file(str(test_file))
+
+        # Should only get the valid lines
+        assert len(coordinates) == 3
+        assert coordinates[0] == (0.0, 0.5)
+        assert coordinates[1] == (1.0, 0.6)
+        assert coordinates[2] == (2.0, 0.7)
+
+    def test_parse_theta_rho_file_whitespace_handling(self, tmp_path):
+        """Test parsing handles various whitespace correctly."""
+        test_file = tmp_path / "whitespace.thr"
+        test_file.write_text("""  0.0 0.5
+	1.0 0.6
+0.0    0.5
+""")
+
+        from modules.core.pattern_manager import parse_theta_rho_file
+
+        coordinates = parse_theta_rho_file(str(test_file))
+
+        assert len(coordinates) == 3
+
+    def test_parse_theta_rho_file_scientific_notation(self, tmp_path):
+        """Test parsing handles scientific notation."""
+        test_file = tmp_path / "scientific.thr"
+        test_file.write_text("""1.5e-3 0.5
+3.14159 1.0e0
+""")
+
+        from modules.core.pattern_manager import parse_theta_rho_file
+
+        coordinates = parse_theta_rho_file(str(test_file))
+
+        assert len(coordinates) == 2
+        assert coordinates[0][0] == pytest.approx(0.0015)
+        assert coordinates[1][1] == pytest.approx(1.0)
+
+    def test_parse_theta_rho_file_negative_values(self, tmp_path):
+        """Test parsing handles negative values."""
+        test_file = tmp_path / "negative.thr"
+        test_file.write_text("""-3.14 0.5
+0.0 -0.5
+-1.0 -0.3
+""")
+
+        from modules.core.pattern_manager import parse_theta_rho_file
+
+        coordinates = parse_theta_rho_file(str(test_file))
+
+        assert len(coordinates) == 3
+        assert coordinates[0] == (-3.14, 0.5)
+        assert coordinates[1] == (0.0, -0.5)
+        assert coordinates[2] == (-1.0, -0.3)
+
+    def test_parse_theta_rho_file_only_comments(self, tmp_path):
+        """Test parsing a file with only comments returns empty list."""
+        test_file = tmp_path / "comments_only.thr"
+        test_file.write_text("""# This file only has comments
+# No actual coordinates
+# Just documentation
+""")
+
+        from modules.core.pattern_manager import parse_theta_rho_file
+
+        coordinates = parse_theta_rho_file(str(test_file))
+
+        assert coordinates == []
+
+    def test_parse_theta_rho_file_empty_file(self, tmp_path):
+        """Test parsing an empty file returns empty list."""
+        test_file = tmp_path / "empty.thr"
+        test_file.write_text("")
+
+        from modules.core.pattern_manager import parse_theta_rho_file
+
+        coordinates = parse_theta_rho_file(str(test_file))
+
+        assert coordinates == []
+
+
+class TestListThetaRhoFiles:
+    """Tests for list_theta_rho_files function."""
+
+    def test_list_theta_rho_files_basic(self, tmp_path):
+        """Test listing pattern files in directory."""
+        # Create test pattern files
+        patterns_dir = tmp_path / "patterns"
+        patterns_dir.mkdir()
+        (patterns_dir / "circle.thr").write_text("0 0.5")
+        (patterns_dir / "spiral.thr").write_text("0 0.5")
+        (patterns_dir / "readme.txt").write_text("not a pattern")
+
+        with patch("modules.core.pattern_manager.THETA_RHO_DIR", str(patterns_dir)):
+            from modules.core.pattern_manager import list_theta_rho_files
+
+            files = list_theta_rho_files()
+
+        # Should only list .thr files
+        assert len(files) == 2
+        assert "circle.thr" in files
+        assert "spiral.thr" in files
+
+    def test_list_theta_rho_files_subdirectories(self, tmp_path):
+        """Test listing pattern files in subdirectories."""
+        patterns_dir = tmp_path / "patterns"
+        patterns_dir.mkdir()
+
+        # Create subdirectory with patterns
+        subdir = patterns_dir / "custom"
+        subdir.mkdir()
+        (subdir / "custom_pattern.thr").write_text("0 0.5")
+        (patterns_dir / "root_pattern.thr").write_text("0 0.5")
+
+        with patch("modules.core.pattern_manager.THETA_RHO_DIR", str(patterns_dir)):
+            from modules.core.pattern_manager import list_theta_rho_files
+
+            files = list_theta_rho_files()
+
+        assert len(files) == 2
+        assert "root_pattern.thr" in files
+        # Subdirectory patterns should include relative path
+        assert "custom/custom_pattern.thr" in files
+
+    def test_list_theta_rho_files_skips_cached_images(self, tmp_path):
+        """Test that cached_images directories are skipped."""
+        patterns_dir = tmp_path / "patterns"
+        patterns_dir.mkdir()
+
+        # Create cached_images directory with files
+        cache_dir = patterns_dir / "cached_images"
+        cache_dir.mkdir()
+        (cache_dir / "preview.thr").write_text("should be skipped")
+
+        (patterns_dir / "real_pattern.thr").write_text("0 0.5")
+
+        with patch("modules.core.pattern_manager.THETA_RHO_DIR", str(patterns_dir)):
+            from modules.core.pattern_manager import list_theta_rho_files
+
+            files = list_theta_rho_files()
+
+        # Should only list the real pattern, not cached files
+        assert len(files) == 1
+        assert "real_pattern.thr" in files
+
+    def test_list_theta_rho_files_empty_directory(self, tmp_path):
+        """Test listing from empty directory returns empty list."""
+        patterns_dir = tmp_path / "patterns"
+        patterns_dir.mkdir()
+
+        with patch("modules.core.pattern_manager.THETA_RHO_DIR", str(patterns_dir)):
+            from modules.core.pattern_manager import list_theta_rho_files
+
+            files = list_theta_rho_files()
+
+        assert files == []

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff