RX24-Mini-Project-Code / servo_animator.py
servo_animator.py
Raw
#!/usr/bin/env python3

# =============================================================================
# Animatronic Servo Animation Controller

# Controls 7 MG90S servos via Pololu Mini Maestro 12ch using Logitech Extreme 3D Pro joystick throttle
# Records, combines, and exports animations to Maestro sequence scripts

# To start, open Command Prompt in the folder containing this file and enter "py servo_animator.py".
# =============================================================================
# Release Version: 2025.1.1
# 2025.1.0 - Initial Release
# 2025.1.1 - added code snippets for simultaneous playback.

# Initial code generated by Claude Sonnet 4 on 2025-08-31
# New code snippets for 2025.1.1 features generated 2025-09-19
# =============================================================================

import pygame
import serial
import time
import json
import os
from typing import List, Dict, Optional, Tuple
from dataclasses import dataclass, asdict
from datetime import datetime

# =============================================================================
# CONFIGURATION VARIABLES - Easy to modify later
# =============================================================================

# Hardware configuration
MAESTRO_DEVICE = "COM4"  # Change this to your Maestro's serial port (Linux: /dev/ttyACM0, Mac: /dev/cu.usbmodem)
MAESTRO_BAUDRATE = 9600  # Standard Maestro baud rate
SERVO_CHANNELS = [0, 2, 3, 4, 5, 6, 7]  # Maestro channels for your 7 servos (0-indexed)

# Timing configuration
COUNTDOWN_TIME = 10.0  # Seconds to count down before recording starts
RECORDING_SAMPLE_RATE = 20  # Hz - how often to record servo positions
PLAYBACK_SPEED = 1.0  # Multiplier for playback speed (1.0 = normal, 0.5 = half speed)

# Servo limits (in microseconds) - Adjust these for your robot's mechanical limits 
DEFAULT_SERVO_LIMITS = {
    "min_position": 600,   # Minimum servo position in microseconds
    "max_position": 2400   # Maximum servo position in microseconds
}

# Background playback configuration
BACKGROUND_PLAYBACK_ENABLED = True  # Set to False to disable background playback feature
BACKGROUND_PLAYBACK_DEFAULT = []    # Default servos for background playback (empty list = none)

# HARD CODED Servo limits (in microseconds) - Customize for your robot's mechanical limits. Number is Servo Channel, make sure it matches SERVO_CHALLENS in line 26 above.
SERVO_LIMITS = {
    0: {"min_position": 608, "max_position": 2496},   # Servo 1 (Channel 0) - Head pan
    2: {"min_position": 608, "max_position": 2496},   # Servo 2 (Channel 1) - Head tilt  
    3: {"min_position": 1792, "max_position": 2352},   # Servo 3 (Channel 2) - Left arm
    4: {"min_position": 1344, "max_position": 2496},   # Servo 4 (Channel 3) - Right arm
    5: {"min_position": 1808, "max_position": 2496},   # Servo 5 (Channel 4) - Body twist
    6: {"min_position": 1552, "max_position": 2256},   # Servo 6 (Channel 5) - Left hand
    7: {"min_position": 960, "max_position": 1952},   # Servo 7 (Channel 6) - Right hand
}



# Joystick configuration
JOYSTICK_DEADZONE = 0.05  # Ignore small joystick movements below this threshold
THROTTLE_AXIS = 3  # Logitech Extreme 3D Pro throttle axis (axis 4, but it indexes from 0, so it's 3)
RECORD_BUTTON = 0  # Button to start/stop recording (trigger button)
PLAY_BUTTON = 1  # Button to play back animation (thumb button)
SERVO_SELECT_BUTTONS = [4, 5, 6, 7, 8, 9, 10]  # Buttons to select servos 1-7

# File configuration
ANIMATIONS_FOLDER = "animations"  # Folder to store animation files
PROJECT_FILE_EXTENSION = ".json"  # File extension for project files
MAESTRO_SCRIPT_EXTENSION = ".txt"  # File extension for Maestro sequence scripts

# =============================================================================
# DATA STRUCTURES
# =============================================================================

@dataclass
class TimestampedPosition:
    """Stores a servo position at a specific time"""
    timestamp: float    # Time in seconds from start of recording
    position: int      # Servo position in microseconds (600-2400 typically)

@dataclass
class ServoLimits:
    """Stores the movement limits for a servo"""
    min_position: int  # Minimum allowed position in microseconds
    max_position: int  # Maximum allowed position in microseconds

@dataclass
class AnimationTrack:
    """Stores all recorded data for one servo"""
    servo_channel: int                    # Which servo channel this track controls
    positions: List[TimestampedPosition]  # All recorded positions with timestamps
    limits: ServoLimits                   # Movement limits for this servo

@dataclass
class AnimationProject:
    """Stores a complete animation project with multiple servo tracks"""
    name: str                             # Project name
    created_date: str                     # When project was created
    duration: float                       # Total duration in seconds
    tracks: Dict[int, AnimationTrack]     # Dictionary mapping servo channel to track data

# =============================================================================
# MAESTRO COMMUNICATION CLASS
# =============================================================================

class MaestroController:
    """Handles all communication with the Pololu Mini Maestro servo controller"""
    
    def __init__(self, device: str, baudrate: int = 9600):
        """
        Initialize connection to Maestro
        Args:
            device: Serial port name (e.g., "COM3" or "/dev/ttyACM0")
            baudrate: Communication speed (9600 is standard for Maestro)
        """
        self.device = device
        self.baudrate = baudrate
        self.serial_connection = None
        self.is_connected = False
        
    def connect(self) -> bool:
        """
        Establish serial connection to Maestro
        Returns:
            True if connection successful, False otherwise
        """
        try:
            # Open serial connection
            self.serial_connection = serial.Serial(self.device, self.baudrate, timeout=1)
            self.is_connected = True
            print(f"✓ Connected to Maestro on {self.device}")
            return True
        except Exception as e:
            print(f"✗ Failed to connect to Maestro: {e}")
            self.is_connected = False
            return False
    
    def disconnect(self):
        """Close the serial connection to Maestro"""
        if self.serial_connection:
            self.serial_connection.close()
            self.is_connected = False
            print("✓ Disconnected from Maestro")
    
    def set_servo_position(self, channel: int, position_us: int):
        """
        Set a servo to a specific position
        Args:
            channel: Servo channel number (0-11 for Mini Maestro 12ch)
            position_us: Position in microseconds (typically 600-2400)
        """
        if not self.is_connected:
            print("✗ Maestro not connected")
            return
        
        try:
            # Convert microseconds to quarter-microseconds (Maestro's native unit)
            target = position_us * 4
            
            # Send Maestro "Set Target" command
            # Command format: 0x84, channel, target_low_byte, target_high_byte
            command = bytes([
                0x84,                    # Set Target command
                channel,                 # Servo channel
                target & 0x7F,          # Target low 7 bits
                (target >> 7) & 0x7F    # Target high 7 bits
            ])
            
            self.serial_connection.write(command)
            
        except Exception as e:
            print(f"✗ Error setting servo position: {e}")

# =============================================================================
# JOYSTICK INPUT CLASS
# =============================================================================

class JoystickController:
    """Handles input from Logitech Extreme 3D Pro joystick"""
    
    def __init__(self):
        """Initialize pygame and joystick"""
        pygame.init()
        pygame.joystick.init()
        
        self.joystick = None
        self.is_connected = False
        
        # Button press tracking (to detect button press events, not just held buttons)
        self.previous_button_states = {}
        
    def connect(self) -> bool:
        """
        Find and connect to the first available joystick
        Returns:
            True if joystick found and connected, False otherwise
        """
        if pygame.joystick.get_count() == 0:
            print("✗ No joystick found")
            return False
        
        try:
            # Connect to first joystick
            self.joystick = pygame.joystick.Joystick(0)
            self.joystick.init()
            self.is_connected = True
            
            # Initialize button state tracking
            self.previous_button_states = {i: False for i in range(self.joystick.get_numbuttons())}
            
            print(f"✓ Connected to joystick: {self.joystick.get_name()}")
            return True
            
        except Exception as e:
            print(f"✗ Failed to connect to joystick: {e}")
            return False
    
    def update(self):
        """Update joystick state - call this every frame"""
        if self.is_connected:
            pygame.event.pump()  # Update pygame event system
    
    def get_throttle_position(self) -> float:
        """
        Get throttle position from joystick
        Returns:
            Throttle position as float between -1.0 and 1.0
        """
        if not self.is_connected:
            return 0.0
        
        # Get raw throttle axis value
        raw_value = self.joystick.get_axis(THROTTLE_AXIS)
        
        # Apply deadzone to ignore small movements
        if abs(raw_value) < JOYSTICK_DEADZONE:
            return 0.0
        
        return raw_value
    
    def is_button_pressed(self, button_number: int) -> bool:
        """
        Check if a button was just pressed (not held down)
        Args:
            button_number: Button index to check
        Returns:
            True if button was just pressed this frame, False otherwise
        """
        if not self.is_connected or button_number >= self.joystick.get_numbuttons():
            return False
        
        # Get current button state
        current_state = self.joystick.get_button(button_number)
        previous_state = self.previous_button_states.get(button_number, False)
        
        # Update button state tracking
        self.previous_button_states[button_number] = current_state
        
        # Button was "pressed" if it's currently down but wasn't down last frame
        return current_state and not previous_state

# =============================================================================
# ANIMATION RECORDING AND PLAYBACK
# =============================================================================

class AnimationRecorder:
    """Handles recording, playback, and management of servo animations"""
    
    def __init__(self, maestro: MaestroController, joystick: JoystickController):
        """
        Initialize the animation recorder
        Args:
            maestro: Connected MaestroController instance
            joystick: Connected JoystickController instance
        """
        self.maestro = maestro
        self.joystick = joystick
        
        # Recording state
        self.is_recording = False
        self.recording_start_time = 0.0
        self.current_recording = []  # List of TimestampedPosition objects
        
        # Playback state
        self.is_playing = False
        self.playback_start_time = 0.0
        self.playback_animation = None
        
        # Current project
        self.current_project = AnimationProject(
            name="Untitled Animation",
            created_date=datetime.now().isoformat(),
            duration=0.0,
            tracks={}
        )
        
        # Servo configuration
        self.active_servo_channel = 0  # Which servo we're currently controlling
        self.servo_limits = {channel: ServoLimits(**DEFAULT_SERVO_LIMITS) for channel in SERVO_CHANNELS}
        
        # Background playback state
        self.background_servos = BACKGROUND_PLAYBACK_DEFAULT.copy()  # List of servo channels for background playback
        self.background_playback_active = False
        self.background_start_time = 0.0

        # Ensure animations folder exists
        if not os.path.exists(ANIMATIONS_FOLDER):
            os.makedirs(ANIMATIONS_FOLDER)
            print(f"✓ Created animations folder: {ANIMATIONS_FOLDER}")
    
    def set_servo_limits(self, channel: int, min_pos: int, max_pos: int):
        """
        Set movement limits for a specific servo
        Args:
            channel: Servo channel to set limits for
            min_pos: Minimum position in microseconds
            max_pos: Maximum position in microseconds
        """
        if channel in SERVO_CHANNELS:
            self.servo_limits[channel] = ServoLimits(min_pos, max_pos)
            print(f"✓ Set servo {channel + 1} limits: {min_pos}-{max_pos} μs")
    
    def constrain_position(self, channel: int, position: int) -> int:
        """
        Ensure a servo position is within the set limits
        Args:
            channel: Servo channel
            position: Desired position in microseconds
        Returns:
            Position constrained to servo limits
        """
        limits = self.servo_limits.get(channel, ServoLimits(**DEFAULT_SERVO_LIMITS))
        return max(limits.min_position, min(limits.max_position, position))
    
    def throttle_to_servo_position(self, throttle_value: float, channel: int) -> int:
        """
        Convert joystick throttle position to servo microseconds
        Args:
            throttle_value: Throttle position (-1.0 to 1.0)
            channel: Servo channel (for limit lookup)
        Returns:
            Servo position in microseconds
        """
        limits = self.servo_limits.get(channel, ServoLimits(**DEFAULT_SERVO_LIMITS))
        
        # Map throttle range (-1.0 to 1.0) to servo range (min to max)
        # Note: -1.0 throttle = max position, 1.0 throttle = min position (typical throttle behavior)
        normalized = (-throttle_value + 1.0) / 2.0  # Convert -1:1 to 0:1, inverted
        position = int(limits.min_position + normalized * (limits.max_position - limits.min_position))
        
        return self.constrain_position(channel, position)
    
    def countdown_before_recording(self):
        """Display a countdown timer before starting recording"""
        print(f"\n🎬 Starting recording countdown for Servo {self.active_servo_channel + 1}...")
        
        for i in range(int(COUNTDOWN_TIME), 0, -1):
            print(f"⏰ Recording starts in {i}...")
            time.sleep(1.0)
        
        print("🔴 RECORDING! Move the throttle to animate the servo.")
    
    def start_recording(self):
        """Begin recording servo positions"""
        if self.is_recording:
            print("✗ Already recording")
            return
        
        # Show countdown
        self.countdown_before_recording()
        
        # Initialize recording
        self.is_recording = True
        self.recording_start_time = time.time()
        self.current_recording = []

        # Start background playback if servos are enabled
        if self.background_servos:
            # Remove active servo from background playback to avoid conflicts
            active_background_servos = [ch for ch in self.background_servos if ch != self.active_servo_channel]
            if len(active_background_servos) != len(self.background_servos):
                print(f"⚠️  Disabled background playback for Servo {self.active_servo_channel + 1} (currently recording)")
            
            if active_background_servos:
                self.background_playback_active = True
                self.background_start_time = self.recording_start_time
                print(f"🔄 Background playback started for servos: {[ch+1 for ch in active_background_servos]}")
        
        print(f"✓ Recording servo {self.active_servo_channel + 1} (Press record button again to stop)")
    
    def stop_recording(self):
        """Stop recording and save the recorded data"""
        if not self.is_recording:
            print("✗ Not currently recording")
            return
        
        self.is_recording = False
        # Stop background playback
        self.background_playback_active = False
        
        if not self.current_recording:
            print("✗ No data recorded")
            return
        
        # Calculate duration
        duration = self.current_recording[-1].timestamp
        
        # Create animation track
        track = AnimationTrack(
            servo_channel=self.active_servo_channel,
            positions=self.current_recording.copy(),
            limits=self.servo_limits[self.active_servo_channel]
        )
        
        # Add track to current project
        self.current_project.tracks[self.active_servo_channel] = track
        self.current_project.duration = max(self.current_project.duration, duration)
        
        print(f"✓ Recording stopped. Captured {len(self.current_recording)} positions over {duration:.2f} seconds")
        print(f"✓ Added to project as Servo {self.active_servo_channel + 1} track")
        
        # Clear current recording
        self.current_recording = []
    
    def update_recording(self):
        """Update recording with current servo position - call this every frame during recording"""
        if not self.is_recording:
            return
        
        # Get current throttle position
        throttle = self.joystick.get_throttle_position()
        servo_position = self.throttle_to_servo_position(throttle, self.active_servo_channel)
        
        # Send position to servo
        self.maestro.set_servo_position(self.active_servo_channel, servo_position)
        
        # Record position with timestamp
        current_time = time.time() - self.recording_start_time
        self.current_recording.append(TimestampedPosition(current_time, servo_position))
        
        # Update background servo playback
        self.update_background_playback()

        # Show live feedback
        if len(self.current_recording) % (RECORDING_SAMPLE_RATE // 2) == 0:  # Print every half second
            print(f"📍 Time: {current_time:.1f}s, Position: {servo_position}μs")
    
    # def start_playback(self):
    #     """Start playing back the complete animation project"""
    #     if not self.current_project.tracks:
    #         print("✗ No animation tracks to play")
    #         return
        
    #     if self.is_playing:
    #         print("✗ Already playing animation")
    #         return
        
    #     self.is_playing = True
    #     self.playback_start_time = time.time()
        
    #     print(f"▶️  Playing animation with {len(self.current_project.tracks)} tracks...")
    #     print(f"   Duration: {self.current_project.duration:.2f} seconds")

    def start_playback(self, loop: bool = False): # AI EDIT - New Playback for looped playback.
        """Start playing back the complete animation project"""
        if not self.current_project.tracks:
            print("✗ No animation tracks to play")
            return
        
        if self.is_playing:
            print("✗ Already playing animation")
            return
        
        self.is_playing = True
        self.playback_start_time = time.time()
        self.loop_playback = loop  # Add this line
        
        loop_text = " (LOOPING)" if loop else ""
        print(f"▶️  Playing animation{loop_text} with {len(self.current_project.tracks)} tracks...")
        print(f"   Duration: {self.current_project.duration:.2f} seconds")

    
    # def update_playback(self):
    #     """Update playback - call this every frame during playback"""
    #     if not self.is_playing:
    #         return
        
    #     # Calculate current playback time
    #     current_time = (time.time() - self.playback_start_time) * PLAYBACK_SPEED
        
    #     # Check if playback is complete
    #     if current_time >= self.current_project.duration:
    #         self.stop_playback()
    #         return
        
    #     # Update each servo track
    #     for channel, track in self.current_project.tracks.items():
    #         position = self.get_position_at_time(track, current_time)
    #         if position is not None:
    #             self.maestro.set_servo_position(channel, position)

    def update_playback(self):      # AI EDIT - New Playback for looped playback.
        """Update playback - call this every frame during playback"""
        if not self.is_playing:
            return
        
        # Calculate current playback time
        elapsed_time = (time.time() - self.playback_start_time) * PLAYBACK_SPEED
        
        # Handle looping
        if hasattr(self, 'loop_playback') and self.loop_playback:
            current_time = elapsed_time % self.current_project.duration  # Loop back to start
        else:
            current_time = elapsed_time
            # Check if playback is complete (non-looping)
            if current_time >= self.current_project.duration:
                self.stop_playback()
                return
        
        # Update each servo track (rest stays the same)
        for channel, track in self.current_project.tracks.items():
            position = self.get_position_at_time(track, current_time)
            if position is not None:
                self.maestro.set_servo_position(channel, position)
        
    def get_position_at_time(self, track: AnimationTrack, time_target: float) -> Optional[int]:
        """
        Get the servo position at a specific time in a track using linear interpolation
        Args:
            track: AnimationTrack to get position from
            time_target: Time to get position for
        Returns:
            Interpolated position in microseconds, or None if no data
        """
        if not track.positions:
            return None
        
        # Handle edge cases
        if time_target <= track.positions[0].timestamp:
            return track.positions[0].position
        if time_target >= track.positions[-1].timestamp:
            return track.positions[-1].position
        
        # Find the two positions to interpolate between
        for i in range(len(track.positions) - 1):
            pos1 = track.positions[i]
            pos2 = track.positions[i + 1]
            
            if pos1.timestamp <= time_target <= pos2.timestamp:
                # Linear interpolation
                time_ratio = (time_target - pos1.timestamp) / (pos2.timestamp - pos1.timestamp)
                interpolated_position = pos1.position + (pos2.position - pos1.position) * time_ratio
                return int(interpolated_position)
        
        return None
    
    def stop_playback(self):
        """Stop animation playback"""
        if self.is_playing:
            self.is_playing = False
            print("⏹️  Playback stopped")
    
    def save_project(self, filename: str):
        """
        Save the current animation project to a file
        Args:
            filename: Name of file to save (without extension)
        """
        filepath = os.path.join(ANIMATIONS_FOLDER, filename + PROJECT_FILE_EXTENSION)
        
        try:
            # Convert project to dictionary for JSON serialization
            project_dict = asdict(self.current_project)
            
            with open(filepath, 'w') as f:
                json.dump(project_dict, f, indent=2)
            
            print(f"✓ Project saved to {filepath}")
            
        except Exception as e:
            print(f"✗ Error saving project: {e}")
    
    def load_project(self, filename: str) -> bool:
        """
        Load an animation project from a file
        Args:
            filename: Name of file to load (without extension)
        Returns:
            True if loaded successfully, False otherwise
        """
        filepath = os.path.join(ANIMATIONS_FOLDER, filename + PROJECT_FILE_EXTENSION)
        
        if not os.path.exists(filepath):
            print(f"✗ File not found: {filepath}")
            return False
        
        try:
            with open(filepath, 'r') as f:
                project_dict = json.load(f)
            
            # Reconstruct project from dictionary
            tracks = {}
            for channel_str, track_dict in project_dict['tracks'].items():
                channel = int(channel_str)
                
                # Reconstruct positions
                positions = [
                    TimestampedPosition(**pos_dict) 
                    for pos_dict in track_dict['positions']
                ]
                
                # Reconstruct limits
                limits = ServoLimits(**track_dict['limits'])
                
                # Reconstruct track
                tracks[channel] = AnimationTrack(
                    servo_channel=track_dict['servo_channel'],
                    positions=positions,
                    limits=limits
                )
            
            # Reconstruct project
            self.current_project = AnimationProject(
                name=project_dict['name'],
                created_date=project_dict['created_date'],
                duration=project_dict['duration'],
                tracks=tracks
            )
            
            print(f"✓ Project loaded: {self.current_project.name}")
            print(f"   Tracks: {list(self.current_project.tracks.keys())}")
            print(f"   Duration: {self.current_project.duration:.2f}s")
            
            return True
            
        except Exception as e:
            print(f"✗ Error loading project: {e}")
            return False
    
    def combine_with_project(self, filename: str):
        """
        Load another project and combine it with the current project
        Args:
            filename: Name of project file to combine with current project
        """
        # Save current project temporarily
        temp_project = self.current_project
        
        # Load the project to combine
        if not self.load_project(filename):
            # Restore original project if load failed
            self.current_project = temp_project
            return
        
        loaded_project = self.current_project
        
        # Restore original project
        self.current_project = temp_project
        
        # Combine tracks
        for channel, track in loaded_project.tracks.items():
            if channel in self.current_project.tracks:
                print(f"⚠️  Warning: Overwriting existing track for servo {channel + 1}")
            
            self.current_project.tracks[channel] = track
            print(f"✓ Added track for servo {channel + 1} from {filename}")
        
        # Update duration
        self.current_project.duration = max(self.current_project.duration, loaded_project.duration)
        
        print(f"✓ Combined projects. New duration: {self.current_project.duration:.2f}s")
    
    # def export_maestro_script(self, filename: str):
    #     """
    #     Export the current project as a Maestro sequence script
    #     Args:
    #         filename: Name of script file to create (without extension)
    #     """
    #     if not self.current_project.tracks:
    #         print("✗ No tracks to export")
    #         return
        
    #     filepath = os.path.join(ANIMATIONS_FOLDER, filename + MAESTRO_SCRIPT_EXTENSION)
        
    #     try:
    #         with open(filepath, 'w') as f:
    #             # Write sequence header
    #             f.write(f"# Maestro Sequence Script: {self.current_project.name}\n")
    #             f.write(f"# Generated: {datetime.now().isoformat()}\n")
    #             f.write(f"# Duration: {self.current_project.duration:.2f} seconds\n")
    #             f.write(f"# Tracks: {list(self.current_project.tracks.keys())}\n\n")
                
    #             # Calculate frame duration in milliseconds
    #             # Using 50ms frames for smooth playback (20 FPS)
    #             frame_duration_ms = 50
    #             total_frames = int((self.current_project.duration * 1000) / frame_duration_ms) + 1
                
    #             f.write(f"# Frame duration: {frame_duration_ms}ms\n")
    #             f.write(f"# Total frames: {total_frames}\n\n")
                
    #             # Write sequence commands
    #             f.write("# Sequence start\n")
    #             f.write("begin\n\n")
                
    #             # Generate frames
    #             for frame in range(total_frames):
    #                 frame_time = (frame * frame_duration_ms) / 1000.0  # Convert to seconds
                    
    #                 f.write(f"# Frame {frame} (t={frame_time:.3f}s)\n")
                    
    #                 # Set position for each servo that has a track
    #                 for channel, track in self.current_project.tracks.items():
    #                     position = self.get_position_at_time(track, frame_time)
    #                     if position is not None:
    #                         # Convert microseconds to quarter-microseconds for Maestro
    #                         target = position * 4
    #                         f.write(f"{target} {channel} servo\n")
                    
    #                 # Add frame delay
    #                 f.write(f"{frame_duration_ms} delay\n\n")
                
    #             # Write sequence end
    #             f.write("# Sequence end\n")
    #             f.write("quit\n")
            
    #         print(f"✓ Maestro script exported to {filepath}")
    #         print(f"   Load this script into Maestro Control Center and save as a sequence")
            
    #     except Exception as e:
    #         print(f"✗ Error exporting Maestro script: {e}")

    def export_maestro_script(self, filename: str, use_keyframes: bool = True, keyframe_threshold: int = 50):
        """
        Export the current project as a Maestro sequence script
        Args:
            filename: Name of script file to create (without extension)
            use_keyframes: If True, use keyframe optimization
            keyframe_threshold: Minimum position change in μs to create keyframe
        """
        if not self.current_project.tracks:
            print("✗ No tracks to export")
            return
        
        filepath = os.path.join(ANIMATIONS_FOLDER, filename + MAESTRO_SCRIPT_EXTENSION)
        
        try:
            with open(filepath, 'w', encoding='utf-8') as f:
                # Write sequence header
                f.write(f"# Maestro Sequence Script: {self.current_project.name}\n")
                f.write(f"# Generated: {datetime.now().isoformat()}\n")
                f.write(f"# Duration: {self.current_project.duration:.2f} seconds\n")
                f.write(f"# Tracks: {list(self.current_project.tracks.keys())}\n")
                f.write(f"# Keyframe optimization: {'ON' if use_keyframes else 'OFF'}\n")
                if use_keyframes:
                    f.write(f"# Keyframe threshold: {keyframe_threshold} μs\n")
                f.write("\n")
                
                if use_keyframes:
                    # Extract keyframes for each track
                    print("🔍 Extracting keyframes...")
                    track_keyframes = {}
                    for channel, track in self.current_project.tracks.items():
                        track_keyframes[channel] = self.extract_keyframes(track, keyframe_threshold)
                    
                    # Create a timeline of all keyframes from all tracks
                    all_keyframes = []
                    for channel, keyframes in track_keyframes.items():
                        for kf in keyframes:
                            all_keyframes.append((kf.timestamp, channel, kf.position))
                    
                    # Sort by timestamp
                    all_keyframes.sort(key=lambda x: x[0])
                    
                    f.write("# Keyframe-based sequence\n")
                    f.write("begin\n\n")
                    
                    current_time = 0.0
                    servo_positions = {}  # Track current position of each servo
                    
                    # Process each keyframe
                    for i, (timestamp, channel, position) in enumerate(all_keyframes):
                        # Calculate delay from last keyframe
                        delay_ms = int((timestamp - current_time) * 1000)
                        
                        if delay_ms > 0:
                            f.write(f"{delay_ms} delay  # Wait {delay_ms}ms\n")
                        
                        # Calculate speed if we know the previous position
                        speed = 0  # Default unlimited speed
                        if channel in servo_positions:
                            prev_position = servo_positions[channel]
                            time_diff = timestamp - current_time if timestamp > current_time else 0.1
                            position_diff = position - prev_position
                            speed = self.calculate_servo_speed(position_diff, time_diff)
                        
                        # Set speed if it's limited
                        if speed > 0:
                            f.write(f"{speed} {channel} speed\n")
                        
                        # Set position
                        target = position * 4  # Convert to quarter-microseconds
                        f.write(f"{target} {channel} servo  # t={timestamp:.2f}s, pos={position}μs\n")
                        
                        # Update tracking
                        servo_positions[channel] = position
                        current_time = timestamp
                        f.write("\n")
                    
                else:
                    # Original frame-based approach
                    frame_duration_ms = 50
                    total_frames = int((self.current_project.duration * 1000) / frame_duration_ms) + 1
                    
                    f.write(f"# Frame-based sequence (50ms frames)\n")
                    f.write(f"# Total frames: {total_frames}\n")
                    f.write("begin\n\n")
                    
                    # Generate frames
                    for frame in range(total_frames):
                        frame_time = (frame * frame_duration_ms) / 1000.0
                        
                        f.write(f"# Frame {frame} (t={frame_time:.3f}s)\n")
                        
                        for channel, track in self.current_project.tracks.items():
                            position = self.get_position_at_time(track, frame_time)
                            if position is not None:
                                target = position * 4
                                f.write(f"{target} {channel} servo\n")
                        
                        f.write(f"{frame_duration_ms} delay\n\n")
                
                # Write sequence end
                f.write("# Sequence end\n")
                f.write("quit\n")
            
            print(f"✓ Maestro script exported to {filepath}")
            
            # Show file size
            file_size = os.path.getsize(filepath)
            print(f"   Script size: {file_size} bytes")
            if file_size > 8000:  # Mini Maestro limit is around 8KB
                print("   ⚠️  Warning: Script may be too large for Mini Maestro")
                print("   Try increasing keyframe_threshold or reducing animation length")
            
        except Exception as e:
            print(f"✗ Error exporting Maestro script: {e}")

    def extract_keyframes(self, track: AnimationTrack, threshold: int = 50) -> List[TimestampedPosition]:
        """
        Extract keyframes where position changes significantly
        Args:
            track: Animation track to process
            threshold: Minimum position change in microseconds to create a keyframe
        Returns:
            List of keyframe positions with timestamps
        """
        if not track.positions:
            return []
        
        keyframes = [track.positions[0]]  # Always include first frame
        
        for i in range(1, len(track.positions)):
            current_pos = track.positions[i]
            last_keyframe = keyframes[-1]
            
            # Add keyframe if position changed significantly OR if it's been too long since last keyframe
            position_change = abs(current_pos.position - last_keyframe.position)
            time_since_last = current_pos.timestamp - last_keyframe.timestamp
            
            # Create keyframe if:
            # 1. Position changed significantly, OR
            # 2. It's been more than 1 second since last keyframe (prevents long gaps)
            if position_change >= threshold or time_since_last >= 1.0:
                keyframes.append(current_pos)
        
        # Always include last frame
        if keyframes[-1] != track.positions[-1]:
            keyframes.append(track.positions[-1])
        
        print(f"    Servo {track.servo_channel + 1}: {len(track.positions)} recorded → {len(keyframes)} keyframes")
        
        return keyframes
    def calculate_servo_speed(self, position_change: int, time_change: float) -> int:
        """
        Calculate Maestro servo speed based on position and time change
        Args:
            position_change: Change in position (microseconds)
            time_change: Time for the change (seconds)
        Returns:
            Maestro speed value (0 = unlimited, 1-127 = limited speed)
        """
        if time_change <= 0:
            return 0  # Unlimited speed for instant moves
        
        # Convert to quarter-microseconds per 10ms (Maestro speed units)
        # Speed = (position_change_in_quarter_us) / (time_in_10ms_units)
        quarter_us_change = abs(position_change * 4)
        time_in_10ms = time_change * 100  # Convert seconds to 10ms units
        
        speed = quarter_us_change / time_in_10ms
        
        # Clamp to Maestro limits (1-127, 0 = unlimited)
        if speed > 127:
            return 0  # Use unlimited speed for very fast movements
        elif speed < 1:
            return 1  # Minimum speed
        else:
            return int(speed)
    def set_background_servos(self, servo_channels: List[int]):
        """
        Set which servos should play in background during recording
        Args:
            servo_channels: List of servo channel numbers for background playback
        """
        # Filter to only include servos that have existing tracks
        valid_servos = [ch for ch in servo_channels if ch in self.current_project.tracks]
        invalid_servos = [ch for ch in servo_channels if ch not in self.current_project.tracks]
        
        if invalid_servos:
            print(f"⚠️  Servos {[ch+1 for ch in invalid_servos]} have no animation data - skipping")
        
        self.background_servos = valid_servos
        print(f"✓ Background playback servos: {[ch+1 for ch in self.background_servos] if self.background_servos else 'None'}")

    def update_background_playback(self):
        """
        Update background servo playback during recording - call every frame during recording
        """
        if not self.background_playback_active or not self.background_servos:
            return
        
        # Calculate current playback time (same timing as main recording)
        current_time = time.time() - self.background_start_time
        
        # Stop background playback if we've exceeded the duration of any background track
        max_duration = 0
        for channel in self.background_servos:
            if channel in self.current_project.tracks:
                track_duration = self.current_project.tracks[channel].positions[-1].timestamp
                max_duration = max(max_duration, track_duration)
        
        if current_time >= max_duration:
            self.background_playback_active = False
            print("🔄 Background playback completed")
            return
        
        # Update each background servo
        for channel in self.background_servos:
            if channel in self.current_project.tracks:
                track = self.current_project.tracks[channel]
                position = self.get_position_at_time(track, current_time)
                if position is not None:
                    self.maestro.set_servo_position(channel, position)

# =============================================================================
# MAIN APPLICATION
# =============================================================================

class ServoAnimatorApp:
    """Main application class that coordinates all components"""
    
    def __init__(self):
        """Initialize the application"""
        print("🤖 Servo Animator MVP Starting...")
        print("=" * 50)
        
        # Initialize components
        self.maestro = MaestroController(MAESTRO_DEVICE, MAESTRO_BAUDRATE)
        self.joystick = JoystickController()
        self.recorder = None  # Will be initialized after connections
        
        self.running = False
    
    def setup_connections(self) -> bool:
        """
        Establish connections to hardware
        Returns:
            True if all connections successful, False otherwise
        """
        print("🔌 Connecting to hardware...")
        
        # Connect to Maestro
        if not self.maestro.connect():
            return False
        
        # Connect to joystick
        if not self.joystick.connect():
            self.maestro.disconnect()
            return False
        
        # Initialize recorder after successful connections
        self.recorder = AnimationRecorder(self.maestro, self.joystick)
        
        print("✓ All hardware connected successfully!")
        return True
    
    # def setup_servo_limits(self):         FIX - ORIGINAL VERSION
    #     """Interactive setup of servo movement limits"""
    #     print("\n⚙️  Servo Limits Configuration")
    #     print("=" * 30)
    #     print("Configure movement limits for each servo to prevent mechanical damage")
    #     print("Press Enter to use default values or type new limits")
    #     print(f"Default limits: {DEFAULT_SERVO_LIMITS['min_position']}-{DEFAULT_SERVO_LIMITS['max_position']} microseconds")
    
        # for channel in SERVO_CHANNELS:
        #     servo_num = channel + 1
        #     print(f"\n🔧 Servo {servo_num} (Channel {channel}):")
            
        #     try:
        #         # Get minimum position
        #         min_input = input(f"  Min position [{DEFAULT_SERVO_LIMITS['min_position']}]: ").strip()
        #         min_pos = int(min_input) if min_input else DEFAULT_SERVO_LIMITS['min_position']
                
        #         # Get maximum position
        #         max_input = input(f"  Max position [{DEFAULT_SERVO_LIMITS['max_position']}]: ").strip()
        #         max_pos = int(max_input) if max_input else DEFAULT_SERVO_LIMITS['max_position']
                
        #         # Validate and set limits
        #         if min_pos >= max_pos:
        #             print(f"  ⚠️  Invalid range, using defaults")
        #             min_pos, max_pos = DEFAULT_SERVO_LIMITS['min_position'], DEFAULT_SERVO_LIMITS['max_position']
                
        #         self.recorder.set_servo_limits(channel, min_pos, max_pos)
                
        #     except ValueError:
        #         print(f"  ⚠️  Invalid input, using defaults for servo {servo_num}")
        #         self.recorder.set_servo_limits(channel, 
        #                                        DEFAULT_SERVO_LIMITS['min_position'], 
        #                                        DEFAULT_SERVO_LIMITS['max_position'])

    def setup_servo_limits(self):
        """Interactive setup of servo movement limits"""
        print("\n⚙️  Servo Limits Configuration")
        print("=" * 30)
        
        # Show current hard-coded limits
        print("Current hard-coded limits:")
        for channel in SERVO_CHANNELS:
            limits = SERVO_LIMITS[channel]
            print(f"  Servo {channel + 1}: {limits['min_position']}-{limits['max_position']} μs")
        
        # Ask if user wants to change them
        response = input("\nUse these limits? (y/n): ").strip().lower()
        
        if response == 'y' or response == '':
            # Use hard-coded limits
            for channel in SERVO_CHANNELS:
                self.recorder.set_servo_limits(channel, 
                                             SERVO_LIMITS[channel]['min_position'],
                                             SERVO_LIMITS[channel]['max_position'])
            print("✓ Using hard-coded servo limits")
            return
        
        # Interactive setup (copy the original code from here down)
        print("Configure movement limits for each servo to prevent mechanical damage")
        print("Press Enter to use default values or type new limits")
        print(f"Default limits: {DEFAULT_SERVO_LIMITS['min_position']}-{DEFAULT_SERVO_LIMITS['max_position']} microseconds")
        
        for channel in SERVO_CHANNELS:
            servo_num = channel + 1
            print(f"\n🔧 Servo {servo_num} (Channel {channel}):")
            
            try:
                # Get minimum position
                min_input = input(f"  Min position [{DEFAULT_SERVO_LIMITS['min_position']}]: ").strip()
                min_pos = int(min_input) if min_input else DEFAULT_SERVO_LIMITS['min_position']
                
                # Get maximum position
                max_input = input(f"  Max position [{DEFAULT_SERVO_LIMITS['max_position']}]: ").strip()
                max_pos = int(max_input) if max_input else DEFAULT_SERVO_LIMITS['max_position']
                
                # Validate and set limits
                if min_pos >= max_pos:
                    print(f"  ⚠️  Invalid range, using defaults")
                    min_pos, max_pos = DEFAULT_SERVO_LIMITS['min_position'], DEFAULT_SERVO_LIMITS['max_position']
                
                self.recorder.set_servo_limits(channel, min_pos, max_pos)
                
            except ValueError:
                print(f"  ⚠️  Invalid input, using defaults for servo {servo_num}")
                self.recorder.set_servo_limits(channel, 
                                               DEFAULT_SERVO_LIMITS['min_position'], 
                                               DEFAULT_SERVO_LIMITS['max_position'])
    
    def select_active_servo(self) -> int:
        """
        Interactive servo selection
        Returns:
            Selected servo channel
        """
        print("\n🎯 Servo Selection")
        print("=" * 20)
        print("Which servo do you want to animate?")
        
        for i, channel in enumerate(SERVO_CHANNELS):
            print(f"  {i + 1}. Servo {channel + 1} (Channel {channel})")
        
        while True:
            try:
                choice = input(f"\nSelect servo (1-{len(SERVO_CHANNELS)}): ").strip()
                servo_index = int(choice) - 1
                
                if 0 <= servo_index < len(SERVO_CHANNELS):
                    selected_channel = SERVO_CHANNELS[servo_index]
                    print(f"✓ Selected Servo {selected_channel + 1} (Channel {selected_channel})")
                    return selected_channel
                else:
                    print(f"✗ Please enter a number between 1 and {len(SERVO_CHANNELS)}")
                    
            except ValueError:
                print("✗ Please enter a valid number")
    
    def show_controls_help(self):
        """Display control instructions"""
        print("\n🎮 Controls")
        print("=" * 15)
        print("Joystick Controls:")
        print(f"  • Throttle: Control servo position")
        print(f"  • Button {RECORD_BUTTON + 1}: Start/Stop recording")
        print(f"  • Button {PLAY_BUTTON + 1}: Play animation")
        print(f"  • Buttons 5-11: Quick servo selection")
        print("\nKeyboard Commands:")
        print("'h' or 'help': Show this help")
        print("'s' or 'servo': Select different servo")
        print("'save': Save current project")
        print("'load': Load existing project")
        print("'combine': Combine with another project")
        print("'export': Export Maestro script")
        print("'q' or 'quit': Exit program")
        print("'test': Test servo movement")
        print("'p' or 'playback': Select background playback servos")
    
    def handle_keyboard_commands(self):
        """Handle keyboard input for various commands"""
        try:
            # Non-blocking input check (this is a simplified approach)
            # In a production app, you'd use threading or async input
            command = input("Command (or 'h' for help): ").strip().lower()
            
            if command in ['h', 'help']:
                self.show_controls_help()
                
            elif command in ['s', 'servo']:
                self.recorder.active_servo_channel = self.select_active_servo()
                
            elif command == 'save':
                filename = input("Enter filename to save: ").strip()
                if filename:
                    self.recorder.save_project(filename)
                
            elif command == 'load':
                filename = input("Enter filename to load: ").strip()
                if filename:
                    self.recorder.load_project(filename)
                    
            elif command == 'combine':
                filename = input("Enter filename to combine with current project: ").strip()
                if filename:
                    self.recorder.combine_with_project(filename)
                    
            elif command == 'export':
                filename = input("Enter filename for Maestro script: ").strip()
                if filename:
                    self.recorder.export_maestro_script(filename)
                    
            elif command == 'test':
                self.test_servo_movement()
                
            elif command in ['q', 'quit']:
                self.running = False
                print("👋 Goodbye!")

            elif command in ['p', 'playback']:
                self.configure_background_servos()
                
            else:
                print("✗ Unknown command. Type 'h' for help.")
                
        except KeyboardInterrupt:
            # Handle Ctrl+C gracefully
            self.running = False
            print("\n👋 Goodbye!")
    
    def test_servo_movement(self):
        """Test servo movement with joystick"""
        print(f"\n🔧 Testing Servo {self.recorder.active_servo_channel + 1}")
        print("Move the throttle to test servo movement. Press any key to stop...")
        
        # Simple test loop
        start_time = time.time()
        while time.time() - start_time < 10:  # Test for 10 seconds max
            # Update joystick
            self.joystick.update()

            # # DEBUG: Add this debug code temporarily
            # print(f"Joystick has {self.joystick.joystick.get_numaxes()} axes")
            # for i in range(self.joystick.joystick.get_numaxes()):
            #     axis_value = self.joystick.joystick.get_axis(i)
            #     print(f"Axis {i}: {axis_value:.3f}")
            # print("Move your throttle and see which axis changes...")
            
            # Get throttle position and move servo
            throttle = self.joystick.get_throttle_position()
            servo_position = self.recorder.throttle_to_servo_position(throttle, self.recorder.active_servo_channel)
            self.maestro.set_servo_position(self.recorder.active_servo_channel, servo_position)
            
            # Show position
            print(f"\rThrottle: {throttle:6.3f} → Position: {servo_position:4d}μs", end="", flush=True)
            
            # Small delay
            time.sleep(0.05)  # 20 FPS
            
            # Check for any button press to exit
            if any(self.joystick.is_button_pressed(i) for i in range(self.joystick.joystick.get_numbuttons())):
                break
        
        print("\n✓ Test complete")
    
    def handle_joystick_input(self):
        """Handle joystick button presses and servo selection"""
        # Check for record button press
        if self.joystick.is_button_pressed(RECORD_BUTTON):
            if self.recorder.is_recording:
                self.recorder.stop_recording()
            else:
                self.recorder.start_recording()
        
        # # Check for play button press
        # if self.joystick.is_button_pressed(PLAY_BUTTON):
        #     if self.recorder.is_playing:
        #         self.recorder.stop_playback()
        #     else:
        #         self.recorder.start_playback()

        # Check for play button press - AI EDIT Added Loop Mode - hold down Button #3 and press Button #2
        if self.joystick.is_button_pressed(PLAY_BUTTON):
            if self.recorder.is_playing:
                self.recorder.stop_playback()
            else:
                # Hold another button for looping (e.g., button 3)
                loop_mode = self.joystick.joystick.get_button(2) if self.joystick.joystick.get_numbuttons() > 2 else False
                self.recorder.start_playback(loop=loop_mode)
                if loop_mode:
                    print("🔄 Loop mode activated - hold button 4 + play for looping")        

        # Check for command mode button (let's use button 3)
        if self.joystick.is_button_pressed(3):  # Button 4 on joystick
            print("\n⌨️  Command Mode - Enter your command:")
            self.handle_keyboard_commands()
            print("✓ Back to animation control")
        # # Check for menu button (let's use button 2)
        # if self.joystick.is_button_pressed(2):  # Button 3 on joystick
        #     print("\n📋 Returning to main menu...")
        #     self.running = False  # This exits the main_loop and returns to interactive_menu
        
        # Check for servo selection buttons (if we have enough buttons)
        for i, button_index in enumerate(SERVO_SELECT_BUTTONS):
            if i < len(SERVO_CHANNELS) and self.joystick.is_button_pressed(button_index):
                self.recorder.active_servo_channel = SERVO_CHANNELS[i]
                print(f"🎯 Switched to Servo {SERVO_CHANNELS[i] + 1}")
                break

        
    
    def main_loop(self):
        """Main application loop"""
        print(f"\n🎬 Animation Control Active")
        print("=" * 30)
        print(f"Active Servo: {self.recorder.active_servo_channel + 1}")
        print(f"Recording: {self.recorder.is_recording}")
        print(f"Playing: {self.recorder.is_playing}")
        self.show_controls_help()
        
        # Main loop
        clock_time = time.time()
        frame_duration = 1.0 / RECORDING_SAMPLE_RATE  # Target frame time
        
        while self.running:
            try:
                current_time = time.time()
                
                # Update joystick input
                self.joystick.update()
                
                # Handle joystick button presses
                self.handle_joystick_input()
                
                # Update recording if active
                if self.recorder.is_recording:
                    self.recorder.update_recording()
                
                # Update playback if active
                if self.recorder.is_playing:
                    self.recorder.update_playback()
                
                # Handle manual servo control when not recording or playing
                if not self.recorder.is_recording and not self.recorder.is_playing:
                    throttle = self.joystick.get_throttle_position()
                    if abs(throttle) > JOYSTICK_DEADZONE:  # Only move if throttle is moved
                        servo_position = self.recorder.throttle_to_servo_position(throttle, self.recorder.active_servo_channel)
                        self.maestro.set_servo_position(self.recorder.active_servo_channel, servo_position)
                
                # Maintain consistent frame rate
                elapsed = current_time - clock_time
                if elapsed < frame_duration:
                    time.sleep(frame_duration - elapsed)
                clock_time = time.time()
                
                # Check for keyboard input periodically (every second)
                if int(current_time) % 1 == 0 and current_time - int(current_time) < frame_duration:
                    # Only check for commands when not recording/playing to avoid interrupting
                    if not self.recorder.is_recording and not self.recorder.is_playing:
                        print(f"\n📊 Status - Servo: {self.recorder.active_servo_channel + 1}, "
                              f"Tracks: {len(self.recorder.current_project.tracks)}, "
                              f"Duration: {self.recorder.current_project.duration:.1f}s")
                        # Note: In a real implementation, you'd handle keyboard input asynchronously
                        # For this MVP, we'll rely primarily on joystick control
                
            except KeyboardInterrupt:
                # Handle Ctrl+C gracefully
                print("\n🛑 Received interrupt signal")
                self.running = False
    
    def interactive_menu(self):
        """Simple interactive menu for file operations"""
        while self.running:
            print(f"\n📋 Servo Animator Menu")
            print("=" * 25)
            print(f"Current Project: {self.recorder.current_project.name}")
            print(f"Tracks: {len(self.recorder.current_project.tracks)}")
            print(f"Duration: {self.recorder.current_project.duration:.2f}s")
            print(f"Active Servo: {self.recorder.active_servo_channel + 1}")
            print()
            print("Options:")
            print("1. Start Animation Control (use joystick)")
            print("2. Test Servo Movement")
            print("3. Change Active Servo")
            print("4. Save Project")
            print("5. Load Project") 
            print("6. Combine Projects")
            print("7. Export Maestro Script")
            print("8. Play Animation (Loop)")  # NEW Add this
            print("9. List Saved Projects")
            print("10. Quit")  # Update the quit number
            print("11. Configure Background Playback Servos")  # Background Servos
            
            try:
                choice = input("\nSelect option (1-9): ").strip()
                
                if choice == '1':
                    # Enter main control loop
                    self.main_loop()
                    
                elif choice == '2':
                    self.test_servo_movement()
                    
                elif choice == '3':
                    self.recorder.active_servo_channel = self.select_active_servo()
                    
                elif choice == '4':
                    filename = input("Enter filename to save: ").strip()
                    if filename:
                        self.recorder.save_project(filename)
                    
                elif choice == '5':
                    self.list_saved_projects()
                    filename = input("Enter filename to load: ").strip()
                    if filename:
                        self.recorder.load_project(filename)
                        
                elif choice == '6':
                    self.list_saved_projects()
                    filename = input("Enter filename to combine: ").strip()
                    if filename:
                        self.recorder.combine_with_project(filename)
                        
                # elif choice == '7':
                #     filename = input("Enter filename for Maestro script: ").strip()
                #     if filename:
                #         self.recorder.export_maestro_script(filename)

                elif choice == '7':
                    filename = input("Enter filename for Maestro script: ").strip()
                    if filename:
                        use_keyframes = input("Use keyframe optimization? (y/n) [y]: ").strip().lower()
                        use_keyframes = use_keyframes != 'n'  # Default to yes
                        
                        if use_keyframes:
                            threshold = input("Keyframe threshold in μs (50): ").strip()
                            threshold = int(threshold) if threshold.isdigit() else 50
                            self.recorder.export_maestro_script(filename, True, threshold)
                        else:
                            self.recorder.export_maestro_script(filename, False)
                elif choice == '8':
                    self.recorder.start_playback(loop=True)
                    print("Press Ctrl+C to stop looping playback")
                    try:
                        while self.recorder.is_playing:
                            self.recorder.update_playback()
                            time.sleep(0.05)  # 20 FPS
                    except KeyboardInterrupt:
                        self.recorder.stop_playback()
                        print("\n⏹️  Looping playback stopped")

                elif choice == '9':  # Update list projects
                    self.list_saved_projects()
                    input("Press Enter to continue...")

                elif choice == '10':  # Update quit
                    self.running = False
                    print("👋 Goodbye!")
                    break        
                
                elif choice == '11':
                    self.configure_background_servos()

                # elif choice == '8':
                #     self.list_saved_projects()
                #     input("Press Enter to continue...")
                    
                # elif choice == '9':
                #     self.running = False
                #     print("👋 Goodbye!")
                #     break
                    
                else:
                    print("✗ Invalid option. Please select 1-11.")
                    
            except KeyboardInterrupt:
                self.running = False
                print("\n👋 Goodbye!")
                break
    
    def list_saved_projects(self):
        """List all saved project files"""
        print(f"\n📁 Saved Projects in {ANIMATIONS_FOLDER}:")
        print("=" * 30)
        
        if not os.path.exists(ANIMATIONS_FOLDER):
            print("No animations folder found.")
            return
        
        project_files = [f for f in os.listdir(ANIMATIONS_FOLDER) if f.endswith(PROJECT_FILE_EXTENSION)]
        
        if not project_files:
            print("No saved projects found.")
            return
        
        for filename in sorted(project_files):
            # Remove extension for display
            project_name = filename[:-len(PROJECT_FILE_EXTENSION)]
            filepath = os.path.join(ANIMATIONS_FOLDER, filename)
            
            try:
                # Get file modification time
                mod_time = os.path.getmtime(filepath)
                mod_time_str = datetime.fromtimestamp(mod_time).strftime("%Y-%m-%d %H:%M")
                
                # Try to get project info
                with open(filepath, 'r') as f:
                    project_data = json.load(f)
                    duration = project_data.get('duration', 0)
                    track_count = len(project_data.get('tracks', {}))
                
                print(f"  📄 {project_name}")
                print(f"      Modified: {mod_time_str}")
                print(f"      Duration: {duration:.2f}s, Tracks: {track_count}")
                print()
                
            except Exception as e:
                print(f"  📄 {project_name} (error reading file: {e})")

    def configure_background_servos(self):
        """Configure which servos should play in background during recording"""
        print(f"\n🔄 Background Playback Configuration")
        print("=" * 35)
        
        # Show available servos with tracks
        available_servos = list(self.recorder.current_project.tracks.keys())
        if not available_servos:
            print("No servos have recorded animation data yet.")
            input("Press Enter to continue...")
            return
        
        print("Available servos with animation data:")
        for channel in sorted(available_servos):
            duration = self.recorder.current_project.tracks[channel].positions[-1].timestamp
            print(f"  Servo {channel + 1} (Channel {channel}) - Duration: {duration:.2f}s")
        
        current_bg = [ch+1 for ch in self.recorder.background_servos]
        print(f"\nCurrently enabled: {current_bg if current_bg else 'None'}")
        
        print(f"\nEnter servo numbers for background playback (e.g., '1,3,5' or 'none'):")
        user_input = input("Background servos: ").strip().lower()
        
        if user_input == 'none' or user_input == '':
            self.recorder.set_background_servos([])
        else:
            try:
                # Parse servo numbers (convert to 0-indexed channels)
                servo_numbers = [int(x.strip()) for x in user_input.split(',')]
                servo_channels = [num - 1 for num in servo_numbers]
                
                # Validate servo numbers are in valid range
                invalid_numbers = [num for num in servo_numbers if num < 1 or num > len(SERVO_CHANNELS)]
                if invalid_numbers:
                    print(f"❌ Invalid servo numbers: {invalid_numbers}")
                    print(f"Valid range: 1-{len(SERVO_CHANNELS)}")
                    return
                
                # Convert to actual channel numbers
                valid_channels = [SERVO_CHANNELS[num-1] for num in servo_numbers if 1 <= num <= len(SERVO_CHANNELS)]
                self.recorder.set_background_servos(valid_channels)
                
            except ValueError:
                print("❌ Invalid input. Use format like '1,3,5' or 'none'")

    def cleanup(self):
        """Clean up resources before exit"""
        print("\n🧹 Cleaning up...")
        
        # Stop any ongoing operations
        if self.recorder:
            if self.recorder.is_recording:
                self.recorder.stop_recording()
            if self.recorder.is_playing:
                self.recorder.stop_playback()
        
        # Disconnect hardware
        if self.maestro:
            self.maestro.disconnect()
        
        # Cleanup pygame
        pygame.quit()
        
        print("✓ Cleanup complete")
    
    def run(self):
        """Main application entry point"""
        try:
            # Setup hardware connections
            if not self.setup_connections():
                print("✗ Failed to connect to hardware. Exiting...")
                return
            
            # Setup servo limits
            self.setup_servo_limits()
            
            # Select initial active servo
            print(f"\n🎯 Initial servo selection:")
            self.recorder.active_servo_channel = self.select_active_servo()
            
            # Start application
            self.running = True
            print(f"\n🚀 Servo Animator MVP Ready!")
            print(f"   Hardware: ✓ Connected")
            print(f"   Servos: 7 configured")
            print(f"   Active Servo: {self.recorder.active_servo_channel + 1}")
            
            # Run interactive menu
            self.interactive_menu()
            
        except Exception as e:
            print(f"✗ Unexpected error: {e}")
            
        finally:
            # Always cleanup
            self.cleanup()

# =============================================================================
# PROGRAM ENTRY POINT
# =============================================================================

if __name__ == "__main__":
    """Main program entry point"""
    
    print("🤖 Animatronic Servo Animation Controller MVP")
    print("=" * 50)
    print("Controls 7 MG90S servos via Pololu Mini Maestro 12ch")
    print("Uses Logitech Extreme 3D Pro joystick for recording")
    print("Version: 1.0 MVP")
    print("=" * 50)
    
    # Check Python version (optional but helpful)
    import sys
    if sys.version_info < (3, 7):
        print("⚠️  Warning: This program requires Python 3.7 or newer")
    
    # Create and run application
    app = ServoAnimatorApp()
    app.run()
    
    print("\n🎬 Thank you for using Servo Animator!")

# =============================================================================
# INSTALLATION INSTRUCTIONS (COMMENTED)
# =============================================================================

"""
INSTALLATION REQUIREMENTS:

1. Install Python 3.7 or newer from python.org

2. Install required packages:
   pip install pygame pyserial

3. Hardware setup:
   - Connect Pololu Mini Maestro 12ch to computer via USB
   - Connect 7 MG90S servos to channels 0-6 on the Maestro
   - Connect Logitech Extreme 3D Pro joystick to computer
   - Power the servos appropriately (5V, adequate current)

4. Configuration:
   - Update MAESTRO_DEVICE variable with your serial port
   - Windows: Usually "COM3", "COM4", etc.
   - Linux: Usually "/dev/ttyACM0" or "/dev/ttyUSB0"  
   - Mac: Usually "/dev/cu.usbmodem*"

5. Usage:
   - Run: python servo_animator.py
   - Follow the interactive setup prompts
   - Use joystick throttle to control servos
   - Press joystick buttons to record/play animations
   - Export animations as Maestro sequence scripts

TROUBLESHOOTING:

- If Maestro connection fails: Check device name and permissions
- If joystick not detected: Ensure it's plugged in and recognized by OS
- If servos don't move: Check power supply and wiring
- If positions are inverted: Adjust throttle_to_servo_position() function

CUSTOMIZATION VARIABLES (at top of file):
- COUNTDOWN_TIME: How long to count down before recording
- RECORDING_SAMPLE_RATE: How often to record positions (Hz)
- DEFAULT_SERVO_LIMITS: Default min/max positions for servos
- JOYSTICK button mappings for different controllers
"""