#!/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 """