tuanchris пре 3 месеци
родитељ
комит
4bbbd3248c

+ 191 - 0
main.py

@@ -132,6 +132,31 @@ async def lifespan(app: FastAPI):
                 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 ball tracking manager for DW LEDs
+            try:
+                from modules.led.ball_tracking_manager import BallTrackingManager
+                controller = state.led_controller.get_controller()
+                config = {
+                    "led_offset": state.ball_tracking_led_offset,
+                    "reversed": state.ball_tracking_reversed,
+                    "spread": state.ball_tracking_spread,
+                    "lookback": state.ball_tracking_lookback,
+                    "brightness": state.ball_tracking_brightness,
+                    "color": state.ball_tracking_color,
+                    "trail_enabled": state.ball_tracking_trail_enabled,
+                    "trail_length": state.ball_tracking_trail_length
+                }
+                state.ball_tracking_manager = BallTrackingManager(controller, state.dw_led_num_leds, config)
+                logger.info("Ball tracking manager initialized")
+
+                # Start tracking if mode is "enabled"
+                if state.ball_tracking_mode == "enabled" and state.ball_tracking_enabled:
+                    state.ball_tracking_manager.start()
+                    logger.info("Ball tracking started (enabled mode)")
+            except Exception as e:
+                logger.warning(f"Failed to initialize ball tracking manager: {e}")
+                state.ball_tracking_manager = None
         else:
             state.led_controller = None
             logger.info("LED controller not configured")
@@ -1297,6 +1322,26 @@ async def set_led_config(request: LEDConfigRequest):
         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}")
 
+        # Initialize ball tracking manager for DW LEDs
+        try:
+            from modules.led.ball_tracking_manager import BallTrackingManager
+            controller = state.led_controller.get_controller()
+            config = {
+                "led_offset": state.ball_tracking_led_offset,
+                "reversed": state.ball_tracking_reversed,
+                "spread": state.ball_tracking_spread,
+                "lookback": state.ball_tracking_lookback,
+                "brightness": state.ball_tracking_brightness,
+                "color": state.ball_tracking_color,
+                "trail_enabled": state.ball_tracking_trail_enabled,
+                "trail_length": state.ball_tracking_trail_length
+            }
+            state.ball_tracking_manager = BallTrackingManager(controller, state.dw_led_num_leds, config)
+            logger.info("Ball tracking manager initialized")
+        except Exception as e:
+            logger.warning(f"Failed to initialize ball tracking manager: {e}")
+            state.ball_tracking_manager = None
+
         # Check if initialization succeeded by checking status
         status = state.led_controller.check_status()
         if not status.get("connected", False) and status.get("error"):
@@ -1923,6 +1968,152 @@ async def dw_leds_get_idle_timeout():
         "remaining_minutes": remaining_minutes
     }
 
+# ==================== Ball Tracking LED Endpoints ====================
+
+@app.post("/api/ball_tracking/config")
+async def set_ball_tracking_config(request: dict):
+    """Update ball tracking configuration"""
+    try:
+        # Update state variables
+        if "enabled" in request:
+            state.ball_tracking_enabled = request["enabled"]
+        if "mode" in request:
+            state.ball_tracking_mode = request["mode"]
+        if "led_offset" in request:
+            state.ball_tracking_led_offset = request["led_offset"]
+        if "reversed" in request:
+            state.ball_tracking_reversed = request["reversed"]
+        if "spread" in request:
+            state.ball_tracking_spread = max(1, min(10, request["spread"]))
+        if "lookback" in request:
+            state.ball_tracking_lookback = max(0, min(15, request["lookback"]))
+        if "brightness" in request:
+            state.ball_tracking_brightness = max(0, min(100, request["brightness"]))
+        if "color" in request:
+            state.ball_tracking_color = request["color"]
+        if "trail_enabled" in request:
+            state.ball_tracking_trail_enabled = request["trail_enabled"]
+        if "trail_length" in request:
+            state.ball_tracking_trail_length = max(1, min(20, request["trail_length"]))
+
+        # Save to state.json
+        state.save()
+
+        # Update manager config if it exists
+        if state.ball_tracking_manager:
+            config = {
+                "led_offset": state.ball_tracking_led_offset,
+                "reversed": state.ball_tracking_reversed,
+                "spread": state.ball_tracking_spread,
+                "lookback": state.ball_tracking_lookback,
+                "brightness": state.ball_tracking_brightness,
+                "color": state.ball_tracking_color,
+                "trail_enabled": state.ball_tracking_trail_enabled,
+                "trail_length": state.ball_tracking_trail_length
+            }
+            state.ball_tracking_manager.update_config(config)
+
+        # Start/stop tracking based on mode if "enabled" mode changes
+        if "mode" in request or "enabled" in request:
+            if state.ball_tracking_mode == "enabled" and state.ball_tracking_enabled:
+                # Always-on mode
+                if state.ball_tracking_manager:
+                    state.ball_tracking_manager.start()
+            elif state.ball_tracking_mode == "disabled" or not state.ball_tracking_enabled:
+                # Disabled
+                if state.ball_tracking_manager:
+                    state.ball_tracking_manager.stop()
+
+        return {
+            "success": True,
+            "message": "Ball tracking configuration updated",
+            "config": {
+                "enabled": state.ball_tracking_enabled,
+                "mode": state.ball_tracking_mode,
+                "led_offset": state.ball_tracking_led_offset,
+                "reversed": state.ball_tracking_reversed,
+                "spread": state.ball_tracking_spread,
+                "lookback": state.ball_tracking_lookback,
+                "brightness": state.ball_tracking_brightness,
+                "color": state.ball_tracking_color,
+                "trail_enabled": state.ball_tracking_trail_enabled,
+                "trail_length": state.ball_tracking_trail_length
+            }
+        }
+    except Exception as e:
+        logger.error(f"Failed to update ball tracking config: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.get("/api/ball_tracking/status")
+async def get_ball_tracking_status():
+    """Get ball tracking status"""
+    try:
+        manager_status = None
+        if state.ball_tracking_manager:
+            manager_status = state.ball_tracking_manager.get_status()
+
+        return {
+            "success": True,
+            "enabled": state.ball_tracking_enabled,
+            "mode": state.ball_tracking_mode,
+            "manager_active": manager_status["active"] if manager_status else False,
+            "manager_status": manager_status,
+            "config": {
+                "led_offset": state.ball_tracking_led_offset,
+                "reversed": state.ball_tracking_reversed,
+                "spread": state.ball_tracking_spread,
+                "lookback": state.ball_tracking_lookback,
+                "brightness": state.ball_tracking_brightness,
+                "color": state.ball_tracking_color,
+                "trail_enabled": state.ball_tracking_trail_enabled,
+                "trail_length": state.ball_tracking_trail_length
+            }
+        }
+    except Exception as e:
+        logger.error(f"Failed to get ball tracking status: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/api/ball_tracking/calibrate")
+async def calibrate_ball_tracking():
+    """
+    Calibration sequence: reset_theta() then move to (0°, rho=1.0)
+    Returns success when ball is at reference position
+    """
+    try:
+        from modules.core import pattern_manager
+
+        # Check if DW LEDs are active
+        if state.led_provider != "dw_leds" or not state.led_controller:
+            raise HTTPException(status_code=400, detail="DW LEDs must be configured for ball tracking")
+
+        # Check if connected
+        if not state.conn:
+            raise HTTPException(status_code=400, detail="Device not connected")
+
+        logger.info("Starting ball tracking calibration")
+
+        # Step 1: Reset theta
+        await pattern_manager.reset_theta()
+        logger.info("Theta reset complete")
+
+        # Step 2: Move to (0°, rho=1.0) at perimeter
+        await pattern_manager.move_polar(0.0, 1.0, state.speed)
+        logger.info("Moved to reference position (0°, 1.0)")
+
+        # Wait for movement to complete
+        await asyncio.sleep(0.5)
+
+        return {
+            "success": True,
+            "message": "Ball moved to reference position (0°, perimeter). Identify which LED is at this position.",
+            "current_theta": state.current_theta,
+            "current_rho": state.current_rho,
+            "num_leds": state.dw_led_num_leds
+        }
+    except Exception as e:
+        logger.error(f"Calibration failed: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
 @app.get("/table_control")
 async def table_control(request: Request):
     return templates.TemplateResponse("table_control.html", {"request": request, "app_name": state.app_name})

+ 20 - 0
modules/core/pattern_manager.py

@@ -321,6 +321,10 @@ class MotionControlThread:
         state.machine_x = new_x_abs
         state.machine_y = new_y_abs
 
+        # Update ball tracking if enabled
+        if state.ball_tracking_enabled and state.ball_tracking_manager:
+            state.ball_tracking_manager.update_position(theta, rho)
+
     def _send_grbl_coordinates_sync(self, x: float, y: float, speed: int = 600, timeout: int = 2, home: bool = False):
         """Synchronous version of send_grbl_coordinates for motion thread."""
         logger.debug(f"Motion thread sending G-code: X{x} Y{y} at F{speed}")
@@ -682,6 +686,11 @@ async def run_theta_rho_file(file_path, is_playlist=False):
             # Cancel idle timeout when playing starts
             idle_timeout_manager.cancel_timeout()
 
+        # Start ball tracking if mode is "playing_only"
+        if state.ball_tracking_mode == "playing_only" and state.ball_tracking_manager:
+            logger.info("Starting ball tracking (playing_only mode)")
+            state.ball_tracking_manager.start()
+
         with tqdm(
             total=total_coordinates,
             unit="coords",
@@ -697,6 +706,9 @@ async def run_theta_rho_file(file_path, is_playlist=False):
                     if state.led_controller:
                         state.led_controller.effect_idle(state.dw_led_idle_effect)
                         start_idle_led_timeout()
+                    # Stop ball tracking on stop
+                    if state.ball_tracking_mode == "playing_only" and state.ball_tracking_manager:
+                        state.ball_tracking_manager.stop()
                     break
 
                 if state.skip_requested:
@@ -705,6 +717,9 @@ async def run_theta_rho_file(file_path, is_playlist=False):
                     if state.led_controller:
                         state.led_controller.effect_idle(state.dw_led_idle_effect)
                         start_idle_led_timeout()
+                    # Stop ball tracking on skip
+                    if state.ball_tracking_mode == "playing_only" and state.ball_tracking_manager:
+                        state.ball_tracking_manager.stop()
                     break
 
                 # Wait for resume if paused (manual or scheduled)
@@ -789,6 +804,11 @@ async def run_theta_rho_file(file_path, is_playlist=False):
             start_idle_led_timeout()
             logger.debug("LED effect set to idle after pattern completion")
 
+        # Stop ball tracking if mode is "playing_only"
+        if state.ball_tracking_mode == "playing_only" and state.ball_tracking_manager:
+            logger.info("Stopping ball tracking (pattern completed)")
+            state.ball_tracking_manager.stop()
+
         # Only clear state if not part of a playlist
         if not is_playlist:
             state.current_playing_file = None

+ 14 - 0
modules/core/state.py

@@ -71,6 +71,20 @@ class AppState:
         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_last_activity_time = None  # Last activity timestamp (runtime only, not persisted)
+
+        # Ball tracking LED settings
+        self.ball_tracking_enabled = False  # Enable ball tracking
+        self.ball_tracking_mode = "disabled"  # "disabled", "enabled", "playing_only"
+        self.ball_tracking_led_offset = 0  # LED offset (0 to num_leds-1)
+        self.ball_tracking_reversed = False  # Reverse LED direction
+        self.ball_tracking_spread = 3  # Number of adjacent LEDs (1-10)
+        self.ball_tracking_lookback = 5  # Coordinates to look back (0-15)
+        self.ball_tracking_brightness = 50  # Brightness 0-100
+        self.ball_tracking_color = "#ffffff"  # Hex color for tracking
+        self.ball_tracking_trail_enabled = False  # Enable fade trail
+        self.ball_tracking_trail_length = 10  # Trail length in LEDs
+        self.ball_tracking_manager = None  # BallTrackingManager instance (runtime only, not persisted)
+
         self.skip_requested = False
         self.table_type = None
         self._playlist_mode = "loop"

+ 212 - 0
modules/led/ball_tracking_manager.py

@@ -0,0 +1,212 @@
+"""
+Ball Tracking LED Manager
+Tracks the ball bearing's position and updates LEDs in real-time to follow its movement.
+"""
+import asyncio
+import time
+import logging
+from collections import deque
+from typing import Optional, Tuple, Dict
+from .dw_led_controller import DWLEDController
+
+logger = logging.getLogger(__name__)
+
+
+class BallTrackingManager:
+    """Manages real-time LED tracking of ball bearing position"""
+
+    def __init__(self, led_controller: DWLEDController, num_leds: int, config: Dict):
+        """
+        Initialize ball tracking manager
+
+        Args:
+            led_controller: DWLEDController instance
+            num_leds: Number of LEDs in the strip
+            config: Configuration dict with keys:
+                - led_offset: LED index offset (0 to num_leds-1)
+                - reversed: Reverse LED direction (bool)
+                - spread: Number of adjacent LEDs to light (1-10)
+                - lookback: Number of coordinates to look back (0-15)
+                - brightness: LED brightness 0-100
+                - color: Hex color string (e.g., "#ffffff")
+                - trail_enabled: Enable fade trail (bool)
+                - trail_length: Trail length in LEDs (1-20)
+        """
+        self.led_controller = led_controller
+        self.num_leds = num_leds
+        self.config = config
+
+        # Coordinate history buffer (max 15 coordinates)
+        self.position_buffer = deque(maxlen=15)
+
+        # Tracking state
+        self._active = False
+        self._update_task = None
+        self._last_led_index = None
+
+        logger.info(f"BallTrackingManager initialized with {num_leds} LEDs")
+
+    def start(self):
+        """Start ball tracking"""
+        if self._active:
+            logger.warning("Ball tracking already active")
+            return
+
+        self._active = True
+        logger.info("Ball tracking started")
+
+    def stop(self):
+        """Stop ball tracking and clear LEDs"""
+        if not self._active:
+            return
+
+        self._active = False
+        self.position_buffer.clear()
+        self._last_led_index = None
+
+        # Clear all LEDs
+        if self.led_controller and self.led_controller._initialized:
+            try:
+                self.led_controller.clear_all_leds()
+            except Exception as e:
+                logger.error(f"Error clearing LEDs: {e}")
+
+        logger.info("Ball tracking stopped")
+
+    def update_position(self, theta: float, rho: float):
+        """
+        Update ball position (called from pattern execution)
+
+        Args:
+            theta: Angular position in degrees (0-360)
+            rho: Radial distance (0.0-1.0)
+        """
+        if not self._active:
+            return
+
+        # Add to buffer
+        self.position_buffer.append((theta, rho, time.time()))
+
+        # Trigger LED update
+        self._update_leds()
+
+    def _update_leds(self):
+        """Update LED strip based on current position"""
+        if not self._active or not self.led_controller or not self.led_controller._initialized:
+            return
+
+        # Get position to track (with lookback)
+        position = self._get_tracked_position()
+        if position is None:
+            return
+
+        theta, rho, _ = position
+
+        # Calculate LED index
+        led_index = self._theta_to_led(theta)
+
+        # Render LEDs
+        self._render_leds(led_index)
+
+        self._last_led_index = led_index
+
+    def _get_tracked_position(self) -> Optional[Tuple[float, float, float]]:
+        """Get position to track (accounting for lookback delay)"""
+        lookback = self.config.get("lookback", 0)
+
+        if len(self.position_buffer) == 0:
+            return None
+
+        # Clamp lookback to buffer size
+        lookback = min(lookback, len(self.position_buffer) - 1)
+        lookback = max(0, lookback)
+
+        # Get position from buffer
+        # Index -1 = most recent, -2 = one back, etc.
+        index = -(lookback + 1)
+        return self.position_buffer[index]
+
+    def _theta_to_led(self, theta: float) -> int:
+        """
+        Convert theta angle to LED index
+
+        Args:
+            theta: Angle in degrees (0-360)
+
+        Returns:
+            LED index (0 to num_leds-1)
+        """
+        # Normalize theta to 0-360
+        theta = theta % 360
+        if theta < 0:
+            theta += 360
+
+        # Calculate LED index (0° = LED 0 before offset)
+        led_index = int((theta / 360.0) * self.num_leds)
+
+        # Apply user-defined offset
+        offset = self.config.get("led_offset", 0)
+        led_index = (led_index + offset) % self.num_leds
+
+        # Reverse direction if needed
+        if self.config.get("reversed", False):
+            led_index = (self.num_leds - led_index) % self.num_leds
+
+        return led_index
+
+    def _render_leds(self, center_led: int):
+        """
+        Render LEDs with spread and optional trail
+
+        Args:
+            center_led: Center LED index to light up
+        """
+        try:
+            spread = self.config.get("spread", 3)
+            brightness = self.config.get("brightness", 50) / 100.0
+            color_hex = self.config.get("color", "#ffffff")
+
+            # Convert hex color to RGB
+            color_hex = color_hex.lstrip('#')
+            r = int(color_hex[0:2], 16)
+            g = int(color_hex[2:4], 16)
+            b = int(color_hex[4:6], 16)
+
+            # Clear previous LEDs first
+            self.led_controller.clear_all_leds()
+
+            # Render with spread
+            half_spread = spread // 2
+            for i in range(-half_spread, half_spread + 1):
+                led_index = (center_led + i) % self.num_leds
+
+                # Calculate intensity fade from center
+                if spread > 1:
+                    distance = abs(i)
+                    intensity = 1.0 - (distance / (spread / 2.0)) * 0.5  # 50-100%
+                else:
+                    intensity = 1.0
+
+                led_brightness = brightness * intensity
+                self.led_controller.set_single_led(led_index, (r, g, b), led_brightness)
+
+            # Show updates
+            if self.led_controller._pixels:
+                self.led_controller._pixels.show()
+
+        except Exception as e:
+            logger.error(f"Error rendering LEDs: {e}")
+
+    def update_config(self, config: Dict):
+        """Update configuration at runtime"""
+        self.config.update(config)
+        logger.info(f"Ball tracking config updated: {config}")
+
+    def get_status(self) -> Dict:
+        """Get current tracking status"""
+        return {
+            "active": self._active,
+            "buffer_size": len(self.position_buffer),
+            "last_led_index": self._last_led_index,
+            "config": self.config
+        }

+ 54 - 0
modules/led/dw_led_controller.py

@@ -491,6 +491,60 @@ class DWLEDController:
 
         return status
 
+    def set_single_led(self, led_index: int, color: Tuple[int, int, int], brightness: float = 1.0) -> bool:
+        """
+        Set a single LED to a specific color (for ball tracking)
+
+        Args:
+            led_index: LED index (0 to num_leds-1)
+            color: RGB tuple (0-255 each)
+            brightness: Brightness multiplier (0.0-1.0)
+
+        Returns:
+            True if successful, False otherwise
+        """
+        if not self._initialized:
+            if not self._initialize_hardware():
+                return False
+
+        if led_index < 0 or led_index >= self.num_leds:
+            logger.warning(f"LED index {led_index} out of range (0-{self.num_leds-1})")
+            return False
+
+        try:
+            with self._lock:
+                if self._pixels:
+                    # Apply brightness to color
+                    r = int(color[0] * brightness)
+                    g = int(color[1] * brightness)
+                    b = int(color[2] * brightness)
+                    self._pixels[led_index] = (r, g, b)
+                    return True
+        except Exception as e:
+            logger.error(f"Error setting LED {led_index}: {e}")
+            return False
+
+    def clear_all_leds(self) -> bool:
+        """
+        Clear all LEDs (set to black)
+
+        Returns:
+            True if successful, False otherwise
+        """
+        if not self._initialized:
+            if not self._initialize_hardware():
+                return False
+
+        try:
+            with self._lock:
+                if self._pixels:
+                    self._pixels.fill((0, 0, 0))
+                    self._pixels.show()
+                    return True
+        except Exception as e:
+            logger.error(f"Error clearing LEDs: {e}")
+            return False
+
     def stop(self):
         """Stop the effect loop and cleanup"""
         self._stop_thread.set()

+ 365 - 0
static/js/settings.js

@@ -1631,3 +1631,368 @@ async function initializeHomingConfig() {
     homingModeSensor.addEventListener('change', updateHomingInfo);
     saveHomingConfigButton.addEventListener('click', saveHomingConfig);
 }
+
+// ==================== Ball Tracking Configuration ====================
+
+async function initBallTracking() {
+    // Elements
+    const ballTrackingEnabled = document.getElementById('ballTrackingEnabled');
+    const ballTrackingSettings = document.getElementById('ballTrackingSettings');
+    const ballTrackingMode = document.getElementById('ballTrackingMode');
+    const ballTrackingSpread = document.getElementById('ballTrackingSpread');
+    const ballTrackingSpreadValue = document.getElementById('ballTrackingSpreadValue');
+    const ballTrackingLookback = document.getElementById('ballTrackingLookback');
+    const ballTrackingLookbackValue = document.getElementById('ballTrackingLookbackValue');
+    const ballTrackingBrightness = document.getElementById('ballTrackingBrightness');
+    const ballTrackingBrightnessValue = document.getElementById('ballTrackingBrightnessValue');
+    const ballTrackingColor = document.getElementById('ballTrackingColor');
+    const saveBallTrackingConfig = document.getElementById('saveBallTrackingConfig');
+
+    // Load current settings
+    try {
+        const response = await fetch('/api/ball_tracking/status');
+        const data = await response.json();
+
+        if (data.success) {
+            ballTrackingEnabled.checked = data.enabled;
+            ballTrackingMode.value = data.mode;
+            ballTrackingSpread.value = data.config.spread;
+            ballTrackingSpreadValue.textContent = `${data.config.spread} LED${data.config.spread > 1 ? 's' : ''}`;
+            ballTrackingLookback.value = data.config.lookback;
+            ballTrackingLookbackValue.textContent = `${data.config.lookback} coord${data.config.lookback !== 1 ? 's' : ''}`;
+            ballTrackingBrightness.value = data.config.brightness;
+            ballTrackingBrightnessValue.textContent = `${data.config.brightness}%`;
+            ballTrackingColor.value = data.config.color;
+
+            // Show settings if enabled
+            if (data.enabled) {
+                ballTrackingSettings.style.display = 'block';
+            }
+        }
+    } catch (error) {
+        console.error('Failed to load ball tracking settings:', error);
+    }
+
+    // Enable/Disable toggle
+    ballTrackingEnabled.addEventListener('change', () => {
+        if (ballTrackingEnabled.checked) {
+            ballTrackingSettings.style.display = 'block';
+        } else {
+            ballTrackingSettings.style.display = 'none';
+        }
+    });
+
+    // Slider value updates
+    ballTrackingSpread.addEventListener('input', () => {
+        const value = parseInt(ballTrackingSpread.value);
+        ballTrackingSpreadValue.textContent = `${value} LED${value > 1 ? 's' : ''}`;
+    });
+
+    ballTrackingLookback.addEventListener('input', () => {
+        const value = parseInt(ballTrackingLookback.value);
+        ballTrackingLookbackValue.textContent = `${value} coord${value !== 1 ? 's' : ''}`;
+    });
+
+    ballTrackingBrightness.addEventListener('input', () => {
+        const value = parseInt(ballTrackingBrightness.value);
+        ballTrackingBrightnessValue.textContent = `${value}%`;
+    });
+
+    // Save configuration
+    saveBallTrackingConfig.addEventListener('click', async () => {
+        const originalHTML = saveBallTrackingConfig.innerHTML;
+        saveBallTrackingConfig.innerHTML = '<span class="material-icons animate-spin">refresh</span><span>Saving...</span>';
+        saveBallTrackingConfig.disabled = true;
+
+        try {
+            const config = {
+                enabled: ballTrackingEnabled.checked,
+                mode: ballTrackingMode.value,
+                spread: parseInt(ballTrackingSpread.value),
+                lookback: parseInt(ballTrackingLookback.value),
+                brightness: parseInt(ballTrackingBrightness.value),
+                color: ballTrackingColor.value
+            };
+
+            const response = await fetch('/api/ball_tracking/config', {
+                method: 'POST',
+                headers: {'Content-Type': 'application/json'},
+                body: JSON.stringify(config)
+            });
+
+            const data = await response.json();
+
+            if (data.success) {
+                showStatusMessage('Ball tracking configuration saved!', 'success');
+            } else {
+                throw new Error(data.message || 'Failed to save configuration');
+            }
+        } catch (error) {
+            console.error('Error saving ball tracking config:', error);
+            showStatusMessage(`Failed to save: ${error.message}`, 'error');
+        } finally {
+            saveBallTrackingConfig.innerHTML = originalHTML;
+            saveBallTrackingConfig.disabled = false;
+        }
+    });
+}
+
+// ==================== Calibration Wizard ====================
+
+function initCalibrationWizard() {
+    // Modal elements
+    const openWizardBtn = document.getElementById('openCalibrationWizard');
+    const calibrationModal = document.getElementById('calibrationModal');
+    const closeModalBtn = document.getElementById('closeCalibrationModal');
+    const closeCompleteBtn = document.getElementById('closeCalibrationComplete');
+
+    // Step elements
+    const step1 = document.getElementById('calibrationStep1');
+    const step2 = document.getElementById('calibrationStep2');
+    const step3 = document.getElementById('calibrationStep3');
+    const complete = document.getElementById('calibrationComplete');
+
+    // Step indicators
+    const step1Indicator = document.getElementById('step1Indicator');
+    const step2Indicator = document.getElementById('step2Indicator');
+    const step3Indicator = document.getElementById('step3Indicator');
+    const line1 = document.getElementById('line1');
+    const line2 = document.getElementById('line2');
+
+    // Action buttons
+    const startMoveBtn = document.getElementById('startCalibrationMove');
+    const moveStatus = document.getElementById('calibrationMoveStatus');
+    const testDirectionBtn = document.getElementById('testDirection');
+    const directionStatus = document.getElementById('directionTestStatus');
+    const directionQuestion = document.getElementById('directionQuestion');
+    const directionClockwise = document.getElementById('directionClockwise');
+    const directionCounterClockwise = document.getElementById('directionCounterClockwise');
+
+    // LED visualization
+    const ledCircle = document.getElementById('ledCircle');
+    const selectedLedInfo = document.getElementById('selectedLedInfo');
+
+    // State
+    let numLeds = 60;
+    let selectedLed = null;
+    let reversed = false;
+
+    // Open wizard
+    openWizardBtn.addEventListener('click', () => {
+        calibrationModal.classList.remove('hidden');
+        resetWizard();
+    });
+
+    // Close wizard
+    function closeWizard() {
+        calibrationModal.classList.add('hidden');
+        resetWizard();
+    }
+
+    closeModalBtn.addEventListener('click', closeWizard);
+    closeCompleteBtn.addEventListener('click', closeWizard);
+
+    // Reset wizard to step 1
+    function resetWizard() {
+        showStep(1);
+        selectedLed = null;
+        reversed = false;
+        moveStatus.classList.add('hidden');
+        directionStatus.classList.add('hidden');
+        directionQuestion.classList.add('hidden');
+    }
+
+    // Show specific step
+    function showStep(stepNum) {
+        // Hide all steps
+        step1.classList.add('hidden');
+        step2.classList.add('hidden');
+        step3.classList.add('hidden');
+        complete.classList.add('hidden');
+
+        // Reset indicators
+        [step1Indicator, step2Indicator, step3Indicator].forEach(ind => {
+            ind.classList.remove('bg-sky-600', 'text-white');
+            ind.classList.add('bg-slate-200', 'text-slate-500');
+        });
+        line1.classList.remove('bg-sky-600');
+        line2.classList.remove('bg-sky-600');
+
+        // Show active step
+        if (stepNum === 1) {
+            step1.classList.remove('hidden');
+            step1Indicator.classList.remove('bg-slate-200', 'text-slate-500');
+            step1Indicator.classList.add('bg-sky-600', 'text-white');
+        } else if (stepNum === 2) {
+            step2.classList.remove('hidden');
+            step1Indicator.classList.remove('bg-slate-200', 'text-slate-500');
+            step1Indicator.classList.add('bg-green-600', 'text-white');
+            step2Indicator.classList.remove('bg-slate-200', 'text-slate-500');
+            step2Indicator.classList.add('bg-sky-600', 'text-white');
+            line1.classList.add('bg-sky-600');
+        } else if (stepNum === 3) {
+            step3.classList.remove('hidden');
+            step1Indicator.classList.add('bg-green-600', 'text-white');
+            step2Indicator.classList.add('bg-green-600', 'text-white');
+            step3Indicator.classList.remove('bg-slate-200', 'text-slate-500');
+            step3Indicator.classList.add('bg-sky-600', 'text-white');
+            line1.classList.add('bg-sky-600');
+            line2.classList.add('bg-sky-600');
+        } else if (stepNum === 'complete') {
+            complete.classList.remove('hidden');
+            [step1Indicator, step2Indicator, step3Indicator].forEach(ind => {
+                ind.classList.add('bg-green-600', 'text-white');
+            });
+            line1.classList.add('bg-sky-600');
+            line2.classList.add('bg-sky-600');
+        }
+    }
+
+    // Step 1: Move to reference
+    startMoveBtn.addEventListener('click', async () => {
+        startMoveBtn.disabled = true;
+        moveStatus.classList.remove('hidden');
+
+        try {
+            const response = await fetch('/api/ball_tracking/calibrate', {
+                method: 'POST',
+                headers: {'Content-Type': 'application/json'}
+            });
+
+            const data = await response.json();
+
+            if (data.success) {
+                numLeds = data.num_leds;
+                showStatusMessage('Ball moved to reference position!', 'success');
+                renderLEDCircle();
+                showStep(2);
+            } else {
+                throw new Error(data.detail || 'Calibration failed');
+            }
+        } catch (error) {
+            console.error('Calibration error:', error);
+            showStatusMessage(`Calibration failed: ${error.message}`, 'error');
+            startMoveBtn.disabled = false;
+            moveStatus.classList.add('hidden');
+        }
+    });
+
+    // Render LED circle
+    function renderLEDCircle() {
+        // Remove existing LEDs
+        const existingLeds = ledCircle.querySelectorAll('.led-dot');
+        existingLeds.forEach(led => led.remove());
+
+        const centerX = 200;
+        const centerY = 200;
+        const radius = 150;
+
+        for (let i = 0; i < numLeds; i++) {
+            const angle = (i / numLeds) * 2 * Math.PI - Math.PI / 2; // Start from top (270°)
+            const x = centerX + radius * Math.cos(angle);
+            const y = centerY + radius * Math.sin(angle);
+
+            const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
+            circle.setAttribute('cx', x);
+            circle.setAttribute('cy', y);
+            circle.setAttribute('r', '6');
+            circle.setAttribute('fill', '#cbd5e1');
+            circle.setAttribute('stroke', '#64748b');
+            circle.setAttribute('stroke-width', '1');
+            circle.setAttribute('class', 'led-dot cursor-pointer hover:fill-sky-400 transition-colors');
+            circle.setAttribute('data-led-index', i);
+
+            circle.addEventListener('click', () => selectLED(i));
+            ledCircle.appendChild(circle);
+        }
+    }
+
+    // Select LED
+    function selectLED(index) {
+        selectedLed = index;
+
+        // Update visuals
+        const allDots = ledCircle.querySelectorAll('.led-dot');
+        allDots.forEach(dot => {
+            dot.setAttribute('fill', '#cbd5e1');
+        });
+        allDots[index].setAttribute('fill', '#0c7ff2');
+
+        selectedLedInfo.textContent = `Selected: LED ${index}`;
+
+        // Move to step 3 after selection
+        setTimeout(() => showStep(3), 500);
+    }
+
+    // Step 3: Test direction
+    testDirectionBtn.addEventListener('click', async () => {
+        testDirectionBtn.disabled = true;
+        directionStatus.classList.remove('hidden');
+
+        try {
+            // Move to 90° to test direction
+            const response = await fetch('/send_coordinate', {
+                method: 'POST',
+                headers: {'Content-Type': 'application/json'},
+                body: JSON.stringify({theta: 90, rho: 1.0})
+            });
+
+            if (response.ok) {
+                await new Promise(resolve => setTimeout(resolve, 2000)); // Wait for movement
+                directionStatus.classList.add('hidden');
+                directionQuestion.classList.remove('hidden');
+            } else {
+                throw new Error('Failed to move ball');
+            }
+        } catch (error) {
+            console.error('Direction test error:', error);
+            showStatusMessage(`Direction test failed: ${error.message}`, 'error');
+            testDirectionBtn.disabled = false;
+            directionStatus.classList.add('hidden');
+        }
+    });
+
+    // Direction confirmation
+    directionClockwise.addEventListener('click', () => completeCalibration(false));
+    directionCounterClockwise.addEventListener('click', () => completeCalibration(true));
+
+    // Complete calibration
+    async function completeCalibration(reverseDirection) {
+        reversed = reverseDirection;
+
+        try {
+            // Save calibration settings
+            const config = {
+                led_offset: selectedLed,
+                reversed: reversed
+            };
+
+            const response = await fetch('/api/ball_tracking/config', {
+                method: 'POST',
+                headers: {'Content-Type': 'application/json'},
+                body: JSON.stringify(config)
+            });
+
+            const data = await response.json();
+
+            if (data.success) {
+                // Show completion
+                document.getElementById('finalLedOffset').textContent = selectedLed;
+                document.getElementById('finalDirection').textContent = reversed ? 'Reversed (Counter-Clockwise)' : 'Normal (Clockwise)';
+                showStep('complete');
+                showStatusMessage('Calibration complete!', 'success');
+            } else {
+                throw new Error(data.message || 'Failed to save calibration');
+            }
+        } catch (error) {
+            console.error('Failed to save calibration:', error);
+            showStatusMessage(`Failed to save: ${error.message}`, 'error');
+        }
+    }
+}
+
+// Initialize on page load
+document.addEventListener('DOMContentLoaded', () => {
+    initBallTracking();
+    initCalibrationWizard();
+});

+ 292 - 0
templates/settings.html

@@ -725,6 +725,298 @@ input:checked + .slider:before {
       </button>
     </div>
   </section>
+
+  <!-- Ball Tracking LED Section -->
+  <section id="ballTrackingSection" class="bg-white rounded-xl shadow-sm overflow-hidden">
+    <h2
+      class="text-slate-800 text-xl sm:text-2xl font-semibold leading-tight tracking-[-0.01em] px-6 py-4 border-b border-slate-200"
+    >
+      Ball Tracking LEDs
+    </h2>
+    <div class="px-6 py-5 space-y-6">
+      <!-- Info Box -->
+      <div class="bg-blue-50 border border-blue-200 rounded-lg p-3">
+        <div class="flex items-start gap-2">
+          <span class="material-icons text-blue-600 text-base">info</span>
+          <div class="text-xs text-blue-700">
+            <p class="font-medium text-blue-800">Track Ball Position with LEDs</p>
+            <p class="mt-1">Illuminate LEDs that follow the ball bearing's movement around the table in real-time. Requires DW LEDs (local GPIO) configuration.</p>
+          </div>
+        </div>
+      </div>
+
+      <!-- Enable Ball Tracking -->
+      <div class="flex items-center justify-between">
+        <div class="flex-1">
+          <h3 class="text-slate-700 text-base font-medium leading-normal">Enable Ball Tracking</h3>
+          <p class="text-xs text-slate-500 mt-1">
+            Turn on LED tracking of ball bearing position
+          </p>
+        </div>
+        <label class="switch">
+          <input type="checkbox" id="ballTrackingEnabled">
+          <span class="slider round"></span>
+        </label>
+      </div>
+
+      <!-- Tracking Settings (shown when enabled) -->
+      <div id="ballTrackingSettings" class="space-y-4" style="display: none;">
+        <!-- Tracking Mode -->
+        <label class="flex flex-col gap-1.5">
+          <span class="text-slate-700 text-sm font-medium leading-normal">Tracking Mode</span>
+          <select
+            id="ballTrackingMode"
+            class="form-select resize-none overflow-hidden rounded-lg text-slate-900 focus:outline-0 focus:ring-2 focus:ring-sky-500 border border-slate-300 bg-white focus:border-sky-500 h-10 px-4 text-base font-medium leading-normal transition-colors"
+          >
+            <option value="disabled">Disabled</option>
+            <option value="playing_only">Only During Pattern Playback</option>
+            <option value="enabled">Always On</option>
+          </select>
+          <p class="text-xs text-slate-500 mt-1">
+            Control when ball tracking is active
+          </p>
+        </label>
+
+        <!-- Calibration Button -->
+        <div class="bg-amber-50 border border-amber-200 rounded-lg p-4">
+          <h4 class="text-slate-800 text-sm font-medium flex items-center gap-2">
+            <span class="material-icons text-amber-600 text-base">tune</span>
+            LED Alignment Calibration
+          </h4>
+          <p class="text-xs text-slate-600 mt-1 mb-3">
+            Calibrate which LED corresponds to the 0° position on your table
+          </p>
+          <button
+            id="openCalibrationWizard"
+            class="flex items-center justify-center gap-2 cursor-pointer rounded-lg h-10 px-4 bg-amber-600 hover:bg-amber-700 text-white text-sm font-medium leading-normal tracking-[0.015em] transition-colors"
+          >
+            <span class="material-icons text-lg">play_arrow</span>
+            <span class="truncate">Start Calibration Wizard</span>
+          </button>
+        </div>
+
+        <!-- Grid of Settings -->
+        <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
+          <!-- LED Spread -->
+          <label class="flex flex-col gap-1.5">
+            <span class="text-slate-700 text-sm font-medium leading-normal">LED Spread</span>
+            <input
+              id="ballTrackingSpread"
+              type="range"
+              min="1"
+              max="10"
+              value="3"
+              class="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer"
+            />
+            <div class="flex justify-between text-xs text-slate-500">
+              <span>1 LED</span>
+              <span id="ballTrackingSpreadValue">3 LEDs</span>
+              <span>10 LEDs</span>
+            </div>
+            <p class="text-xs text-slate-500">
+              Number of adjacent LEDs to light up
+            </p>
+          </label>
+
+          <!-- Look-back Delay -->
+          <label class="flex flex-col gap-1.5">
+            <span class="text-slate-700 text-sm font-medium leading-normal">Look-back Delay</span>
+            <input
+              id="ballTrackingLookback"
+              type="range"
+              min="0"
+              max="15"
+              value="5"
+              class="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer"
+            />
+            <div class="flex justify-between text-xs text-slate-500">
+              <span>0</span>
+              <span id="ballTrackingLookbackValue">5 coords</span>
+              <span>15</span>
+            </div>
+            <p class="text-xs text-slate-500">
+              Compensate for motion lag by tracking past position
+            </p>
+          </label>
+
+          <!-- Brightness -->
+          <label class="flex flex-col gap-1.5">
+            <span class="text-slate-700 text-sm font-medium leading-normal">Brightness</span>
+            <input
+              id="ballTrackingBrightness"
+              type="range"
+              min="0"
+              max="100"
+              value="50"
+              class="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer"
+            />
+            <div class="flex justify-between text-xs text-slate-500">
+              <span>0%</span>
+              <span id="ballTrackingBrightnessValue">50%</span>
+              <span>100%</span>
+            </div>
+          </label>
+
+          <!-- Color Picker -->
+          <label class="flex flex-col gap-1.5">
+            <span class="text-slate-700 text-sm font-medium leading-normal">Tracking Color</span>
+            <input
+              id="ballTrackingColor"
+              type="color"
+              value="#ffffff"
+              class="w-full h-10 rounded-lg cursor-pointer border border-slate-300"
+            />
+            <p class="text-xs text-slate-500">
+              Color of tracking LEDs
+            </p>
+          </label>
+        </div>
+
+        <!-- Save Button -->
+        <div class="flex justify-end">
+          <button
+            id="saveBallTrackingConfig"
+            class="flex items-center justify-center gap-2 min-w-[140px] cursor-pointer rounded-lg h-10 px-4 bg-sky-600 hover:bg-sky-700 text-white text-sm font-medium leading-normal tracking-[0.015em] transition-colors"
+          >
+            <span class="material-icons text-lg">save</span>
+            <span class="truncate">Save Settings</span>
+          </button>
+        </div>
+      </div>
+    </div>
+  </section>
+
+  <!-- Calibration Wizard Modal -->
+  <div id="calibrationModal" class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden flex items-center justify-center p-4">
+    <div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
+      <!-- Modal Header -->
+      <div class="flex items-center justify-between px-6 py-4 border-b border-slate-200">
+        <h3 class="text-slate-900 text-xl font-semibold">Ball Tracking Calibration</h3>
+        <button id="closeCalibrationModal" class="text-slate-400 hover:text-slate-600">
+          <span class="material-icons">close</span>
+        </button>
+      </div>
+
+      <!-- Modal Content -->
+      <div class="px-6 py-6">
+        <!-- Step Indicator -->
+        <div class="flex items-center justify-between mb-6">
+          <div class="flex items-center gap-2">
+            <div id="step1Indicator" class="flex items-center justify-center w-8 h-8 rounded-full bg-sky-600 text-white text-sm font-medium">1</div>
+            <div class="w-16 h-1 bg-slate-200" id="line1"></div>
+            <div id="step2Indicator" class="flex items-center justify-center w-8 h-8 rounded-full bg-slate-200 text-slate-500 text-sm font-medium">2</div>
+            <div class="w-16 h-1 bg-slate-200" id="line2"></div>
+            <div id="step3Indicator" class="flex items-center justify-center w-8 h-8 rounded-full bg-slate-200 text-slate-500 text-sm font-medium">3</div>
+          </div>
+        </div>
+
+        <!-- Step 1: Move to Reference -->
+        <div id="calibrationStep1" class="space-y-4">
+          <h4 class="text-lg font-medium text-slate-800">Step 1: Move to Reference Position</h4>
+          <p class="text-sm text-slate-600">
+            We'll reset theta and move the ball to the reference position (0°, perimeter). This will help us identify which LED corresponds to the 0° position.
+          </p>
+          <button
+            id="startCalibrationMove"
+            class="flex items-center justify-center gap-2 w-full cursor-pointer rounded-lg h-12 px-4 bg-sky-600 hover:bg-sky-700 text-white text-base font-medium transition-colors"
+          >
+            <span class="material-icons text-xl">navigation</span>
+            <span>Move Ball to Reference Position</span>
+          </button>
+          <div id="calibrationMoveStatus" class="hidden text-sm text-slate-600 text-center">
+            <span class="inline-block animate-spin material-icons text-sky-600">refresh</span>
+            Moving to reference position...
+          </div>
+        </div>
+
+        <!-- Step 2: Identify LED -->
+        <div id="calibrationStep2" class="space-y-4 hidden">
+          <h4 class="text-lg font-medium text-slate-800">Step 2: Identify LED Position</h4>
+          <p class="text-sm text-slate-600 mb-4">
+            The ball is now at the 0° reference position (perimeter). Click on the LED that is at this position in the circular diagram below.
+          </p>
+          <!-- LED Circle Visualization -->
+          <div class="flex justify-center my-6">
+            <svg id="ledCircle" viewBox="0 0 400 400" class="w-full max-w-md">
+              <!-- Circle representing LED strip -->
+              <circle cx="200" cy="200" r="150" fill="none" stroke="#e2e8f0" stroke-width="2"/>
+              <!-- 0° marker (East) -->
+              <line x1="350" y1="200" x2="370" y2="200" stroke="#0c7ff2" stroke-width="3"/>
+              <text x="380" y="205" fill="#0c7ff2" font-size="14" font-weight="bold">0°</text>
+              <!-- LEDs will be added here by JavaScript -->
+            </svg>
+          </div>
+          <p id="selectedLedInfo" class="text-sm text-center text-slate-600">
+            No LED selected
+          </p>
+        </div>
+
+        <!-- Step 3: Test Direction -->
+        <div id="calibrationStep3" class="space-y-4 hidden">
+          <h4 class="text-lg font-medium text-slate-800">Step 3: Test Direction</h4>
+          <p class="text-sm text-slate-600 mb-4">
+            We'll move the ball to 90° to test LED direction. Watch which way the LEDs move.
+          </p>
+          <button
+            id="testDirection"
+            class="flex items-center justify-center gap-2 w-full cursor-pointer rounded-lg h-12 px-4 bg-sky-600 hover:bg-sky-700 text-white text-base font-medium transition-colors"
+          >
+            <span class="material-icons text-xl">rotate_right</span>
+            <span>Move to 90° and Test</span>
+          </button>
+          <div id="directionTestStatus" class="hidden text-sm text-slate-600 text-center">
+            <span class="inline-block animate-spin material-icons text-sky-600">refresh</span>
+            Testing direction...
+          </div>
+          <div id="directionQuestion" class="hidden space-y-4 mt-4">
+            <p class="text-sm text-slate-700 font-medium text-center">Did the LED move clockwise?</p>
+            <div class="grid grid-cols-2 gap-4">
+              <button
+                id="directionClockwise"
+                class="flex items-center justify-center gap-2 cursor-pointer rounded-lg h-12 px-4 bg-green-600 hover:bg-green-700 text-white text-base font-medium transition-colors"
+              >
+                <span class="material-icons">check_circle</span>
+                <span>Yes, Clockwise</span>
+              </button>
+              <button
+                id="directionCounterClockwise"
+                class="flex items-center justify-center gap-2 cursor-pointer rounded-lg h-12 px-4 bg-orange-600 hover:bg-orange-700 text-white text-base font-medium transition-colors"
+              >
+                <span class="material-icons">sync</span>
+                <span>No, Reverse It</span>
+              </button>
+            </div>
+          </div>
+        </div>
+
+        <!-- Completion -->
+        <div id="calibrationComplete" class="space-y-4 hidden text-center">
+          <div class="flex justify-center">
+            <div class="w-20 h-20 rounded-full bg-green-100 flex items-center justify-center">
+              <span class="material-icons text-green-600 text-5xl">check_circle</span>
+            </div>
+          </div>
+          <h4 class="text-lg font-medium text-slate-800">Calibration Complete!</h4>
+          <p class="text-sm text-slate-600">
+            LED alignment has been calibrated. Ball tracking is now ready to use.
+          </p>
+          <div class="bg-slate-50 rounded-lg p-4 text-left">
+            <p class="text-xs text-slate-600">
+              <strong>LED Offset:</strong> <span id="finalLedOffset"></span><br>
+              <strong>Direction:</strong> <span id="finalDirection"></span>
+            </p>
+          </div>
+          <button
+            id="closeCalibrationComplete"
+            class="flex items-center justify-center gap-2 w-full cursor-pointer rounded-lg h-12 px-4 bg-sky-600 hover:bg-sky-700 text-white text-base font-medium transition-colors"
+          >
+            <span>Done</span>
+          </button>
+        </div>
+      </div>
+    </div>
+  </div>
+
   <section class="bg-white rounded-xl shadow-sm overflow-hidden">
     <h2
       class="text-slate-800 text-xl sm:text-2xl font-semibold leading-tight tracking-[-0.01em] px-6 py-4 border-b border-slate-200"