Spaces:
Running
Running
| /** | |
| * SO-100 Follower Robot implementation for Node.js | |
| * Mirrors Python lerobot/common/robots/so100_follower/so100_follower.py | |
| */ | |
| import { Robot } from "./robot.js"; | |
| import type { RobotConfig } from "../types/robot-config.js"; | |
| import * as readline from "readline"; | |
| export class SO100Follower extends Robot { | |
| constructor(config: RobotConfig) { | |
| super(config); | |
| // Validate that this is an SO-100 follower config | |
| if (config.type !== "so100_follower") { | |
| throw new Error( | |
| `Invalid robot type: ${config.type}. Expected: so100_follower` | |
| ); | |
| } | |
| } | |
| /** | |
| * Calibrate the SO-100 follower robot | |
| * NOTE: Calibration logic has been moved to shared/common/calibration.ts | |
| * This method is kept for backward compatibility but delegates to the main calibrate.ts | |
| */ | |
| async calibrate(): Promise<void> { | |
| throw new Error( | |
| "Direct device calibration is deprecated. Use the main calibrate.ts orchestrator instead." | |
| ); | |
| } | |
| /** | |
| * Initialize robot communication | |
| * For now, just test basic serial connectivity | |
| */ | |
| private async initializeRobot(): Promise<void> { | |
| console.log("Initializing robot communication..."); | |
| try { | |
| // For SO-100, we need to implement Feetech servo protocol | |
| // For now, just test that we can send/receive data | |
| console.log("Testing serial port connectivity..."); | |
| // Try to ping servo ID 1 (shoulder_pan motor) | |
| // This is a very basic test - real implementation needs proper Feetech protocol | |
| const pingPacket = Buffer.from([0xff, 0xff, 0x01, 0x02, 0x01, 0xfb]); // Basic ping packet | |
| if (!this.port || !this.port.isOpen) { | |
| throw new Error("Serial port not open"); | |
| } | |
| // Send ping packet | |
| await new Promise<void>((resolve, reject) => { | |
| this.port!.write(pingPacket, (error) => { | |
| if (error) { | |
| reject(new Error(`Failed to send ping: ${error.message}`)); | |
| } else { | |
| resolve(); | |
| } | |
| }); | |
| }); | |
| console.log("Ping packet sent successfully"); | |
| // Try to read response with shorter timeout | |
| try { | |
| const response = await this.readData(1000); // 1 second timeout | |
| console.log(`Response received: ${response.length} bytes`); | |
| } catch (error) { | |
| console.log("No response received (expected for basic test)"); | |
| } | |
| } catch (error) { | |
| throw new Error( | |
| `Serial communication test failed: ${ | |
| error instanceof Error ? error.message : error | |
| }` | |
| ); | |
| } | |
| console.log("Robot communication test completed."); | |
| } | |
| /** | |
| * Read current motor positions as a record with motor names | |
| * For teleoperation use | |
| */ | |
| async getMotorPositions(): Promise<Record<string, number>> { | |
| const positions = await this.readMotorPositions(); | |
| const motorNames = [ | |
| "shoulder_pan", | |
| "shoulder_lift", | |
| "elbow_flex", | |
| "wrist_flex", | |
| "wrist_roll", | |
| "gripper", | |
| ]; | |
| const result: Record<string, number> = {}; | |
| for (let i = 0; i < motorNames.length; i++) { | |
| result[motorNames[i]] = positions[i]; | |
| } | |
| return result; | |
| } | |
| /** | |
| * Get calibration data for teleoperation | |
| * Returns position limits and offsets from calibration file | |
| */ | |
| getCalibrationLimits(): Record<string, { min: number; max: number }> { | |
| if (!this.isCalibrated || !this.calibration) { | |
| console.warn("No calibration data available, using default limits"); | |
| // Default STS3215 limits as fallback | |
| return { | |
| shoulder_pan: { min: 985, max: 3085 }, | |
| shoulder_lift: { min: 1200, max: 2800 }, | |
| elbow_flex: { min: 1000, max: 3000 }, | |
| wrist_flex: { min: 1100, max: 2900 }, | |
| wrist_roll: { min: 0, max: 4095 }, // Full rotation motor | |
| gripper: { min: 1800, max: 2300 }, | |
| }; | |
| } | |
| // Extract limits from calibration data (matches Python format) | |
| const limits: Record<string, { min: number; max: number }> = {}; | |
| for (const [motorName, calibData] of Object.entries(this.calibration)) { | |
| if ( | |
| calibData && | |
| typeof calibData === "object" && | |
| "range_min" in calibData && | |
| "range_max" in calibData | |
| ) { | |
| limits[motorName] = { | |
| min: Number(calibData.range_min), | |
| max: Number(calibData.range_max), | |
| }; | |
| } | |
| } | |
| return limits; | |
| } | |
| /** | |
| * Set motor positions from a record with motor names | |
| * For teleoperation use | |
| */ | |
| async setMotorPositions(positions: Record<string, number>): Promise<void> { | |
| const motorNames = [ | |
| "shoulder_pan", | |
| "shoulder_lift", | |
| "elbow_flex", | |
| "wrist_flex", | |
| "wrist_roll", | |
| "gripper", | |
| ]; | |
| const motorIds = [1, 2, 3, 4, 5, 6]; // SO-100 has servo IDs 1-6 | |
| for (let i = 0; i < motorNames.length; i++) { | |
| const motorName = motorNames[i]; | |
| const motorId = motorIds[i]; | |
| const position = positions[motorName]; | |
| if (position !== undefined) { | |
| await this.writeMotorPosition(motorId, position); | |
| } | |
| } | |
| } | |
| /** | |
| * Write position to a single motor | |
| * Implements STS3215 WRITE_DATA command for position control | |
| */ | |
| private async writeMotorPosition( | |
| motorId: number, | |
| position: number | |
| ): Promise<void> { | |
| if (!this.port || !this.port.isOpen) { | |
| throw new Error("Serial port not open"); | |
| } | |
| // Clamp position to valid range | |
| const clampedPosition = Math.max(0, Math.min(4095, Math.round(position))); | |
| // Create STS3215 Write Position packet | |
| // Format: [0xFF, 0xFF, ID, Length, Instruction, Address, Data_L, Data_H, Checksum] | |
| // Goal_Position address for STS3215 is 42 (0x2A), length 2 bytes | |
| const packet = Buffer.from([ | |
| 0xff, | |
| 0xff, // Header | |
| motorId, // Servo ID | |
| 0x05, // Length (Instruction + Address + Data_L + Data_H + Checksum) | |
| 0x03, // Instruction: WRITE_DATA | |
| 0x2a, // Address: Goal_Position (42) | |
| clampedPosition & 0xff, // Data_L (low byte) | |
| (clampedPosition >> 8) & 0xff, // Data_H (high byte) | |
| 0x00, // Checksum (will calculate) | |
| ]); | |
| // Calculate checksum: ~(ID + Length + Instruction + Address + Data_L + Data_H) & 0xFF | |
| const checksum = | |
| ~( | |
| motorId + | |
| 0x05 + | |
| 0x03 + | |
| 0x2a + | |
| (clampedPosition & 0xff) + | |
| ((clampedPosition >> 8) & 0xff) | |
| ) & 0xff; | |
| packet[8] = checksum; | |
| // Send write position packet | |
| await new Promise<void>((resolve, reject) => { | |
| this.port!.write(packet, (error) => { | |
| if (error) { | |
| reject(new Error(`Failed to send write packet: ${error.message}`)); | |
| } else { | |
| resolve(); | |
| } | |
| }); | |
| }); | |
| // Small delay to allow servo to process command | |
| await new Promise((resolve) => setTimeout(resolve, 1)); | |
| } | |
| /** | |
| * Read current motor positions | |
| * Implements basic STS3215 servo protocol to read actual positions | |
| */ | |
| private async readMotorPositions(): Promise<number[]> { | |
| console.log("Reading motor positions..."); | |
| const motorPositions: number[] = []; | |
| const motorIds = [1, 2, 3, 4, 5, 6]; // SO-100 has servo IDs 1-6 | |
| const motorNames = [ | |
| "shoulder_pan", | |
| "shoulder_lift", | |
| "elbow_flex", | |
| "wrist_flex", | |
| "wrist_roll", | |
| "gripper", | |
| ]; | |
| // Try to read position from each servo using STS3215 protocol | |
| for (let i = 0; i < motorIds.length; i++) { | |
| const motorId = motorIds[i]; | |
| const motorName = motorNames[i]; | |
| try { | |
| console.log(` Reading ${motorName} (ID ${motorId})...`); | |
| // Create STS3215 Read Position packet | |
| // Format: [0xFF, 0xFF, ID, Length, Instruction, Address, DataLength, Checksum] | |
| // Present_Position address for STS3215 is 56 (0x38), length 2 bytes | |
| const packet = Buffer.from([ | |
| 0xff, | |
| 0xff, // Header | |
| motorId, // Servo ID | |
| 0x04, // Length (Instruction + Address + DataLength + Checksum) | |
| 0x02, // Instruction: READ_DATA | |
| 0x38, // Address: Present_Position (56) | |
| 0x02, // Data Length: 2 bytes | |
| 0x00, // Checksum (will calculate) | |
| ]); | |
| // Calculate checksum: ~(ID + Length + Instruction + Address + DataLength) & 0xFF | |
| const checksum = ~(motorId + 0x04 + 0x02 + 0x38 + 0x02) & 0xff; | |
| packet[7] = checksum; | |
| if (!this.port || !this.port.isOpen) { | |
| throw new Error("Serial port not open"); | |
| } | |
| // Send read position packet | |
| await new Promise<void>((resolve, reject) => { | |
| this.port!.write(packet, (error) => { | |
| if (error) { | |
| reject(new Error(`Failed to send read packet: ${error.message}`)); | |
| } else { | |
| resolve(); | |
| } | |
| }); | |
| }); | |
| // Try to read response (timeout after 500ms) | |
| try { | |
| const response = await this.readData(500); | |
| if (response.length >= 7) { | |
| // Parse response: [0xFF, 0xFF, ID, Length, Error, Data_L, Data_H, Checksum] | |
| const id = response[2]; | |
| const error = response[4]; | |
| if (id === motorId && error === 0) { | |
| // Extract 16-bit position from Data_L and Data_H | |
| const position = response[5] | (response[6] << 8); | |
| motorPositions.push(position); | |
| // Show calibrated range if available | |
| const calibratedLimits = this.getCalibrationLimits(); | |
| const limits = calibratedLimits[motorName]; | |
| const rangeText = limits | |
| ? `(${limits.min}-${limits.max} calibrated)` | |
| : `(0-4095 raw)`; | |
| console.log(` ${motorName}: ${position} ${rangeText}`); | |
| } else { | |
| console.warn( | |
| ` ${motorName}: Error response (error code: ${error})` | |
| ); | |
| motorPositions.push(2047); // Use center position as fallback | |
| } | |
| } else { | |
| console.warn(` ${motorName}: Invalid response length`); | |
| motorPositions.push(2047); // Use center position as fallback | |
| } | |
| } catch (readError) { | |
| console.warn( | |
| ` ${motorName}: Read timeout - using fallback position` | |
| ); | |
| motorPositions.push(2047); // Use center position as fallback | |
| } | |
| } catch (error) { | |
| console.warn( | |
| ` ${motorName}: Communication error - ${ | |
| error instanceof Error ? error.message : error | |
| }` | |
| ); | |
| motorPositions.push(2047); // Use center position as fallback | |
| } | |
| // Small delay between servo reads | |
| await new Promise((resolve) => setTimeout(resolve, 10)); | |
| } | |
| console.log(`Motor positions: [${motorPositions.join(", ")}]`); | |
| return motorPositions; | |
| } | |
| /** | |
| * Set motor limits and safety parameters | |
| * TODO: Implement proper Feetech servo protocol | |
| */ | |
| private async setMotorLimits(): Promise<any> { | |
| console.log("Setting motor limits..."); | |
| // Set default limits for SO-100 (based on Python implementation) | |
| const limits = { | |
| position_min: [-180, -90, -90, -90, -90, -90], | |
| position_max: [180, 90, 90, 90, 90, 90], | |
| velocity_max: [100, 100, 100, 100, 100, 100], | |
| torque_max: [50, 50, 50, 50, 25, 25], | |
| }; | |
| // For now, just return the limits without sending to robot | |
| // Real implementation needs Feetech servo protocol to set limits | |
| console.log("Motor limits configured (mock)."); | |
| return limits; | |
| } | |
| /** | |
| * Interactive calibration process - matches Python lerobot calibration flow | |
| * Implements real calibration with user interaction | |
| */ | |
| private async calibrateMotors(): Promise<any> { | |
| console.log("\n=== INTERACTIVE CALIBRATION ==="); | |
| console.log("Starting SO-100 follower arm calibration..."); | |
| // Step 1: Move to middle position and record homing offsets | |
| console.log("\n📍 STEP 1: Set Homing Position"); | |
| await this.promptUser( | |
| "Move the SO-100 to the MIDDLE of its range of motion and press ENTER..." | |
| ); | |
| const homingOffsets = await this.setHomingOffsets(); | |
| // Step 2: Record ranges of motion | |
| console.log("\n📏 STEP 2: Record Joint Ranges"); | |
| const { rangeMins, rangeMaxes } = await this.recordRangesOfMotion(); | |
| // Step 3: Set special range for wrist_roll (full turn motor) | |
| console.log("\n🔄 STEP 3: Configure Full-Turn Motor"); | |
| console.log("Setting wrist_roll as full-turn motor (0-4095 range)"); | |
| rangeMins["wrist_roll"] = 0; | |
| rangeMaxes["wrist_roll"] = 4095; | |
| // Step 4: Compile calibration results | |
| const motorNames = [ | |
| "shoulder_pan", | |
| "shoulder_lift", | |
| "elbow_flex", | |
| "wrist_flex", | |
| "wrist_roll", | |
| "gripper", | |
| ]; | |
| const results = []; | |
| for (let i = 0; i < motorNames.length; i++) { | |
| const motorId = i + 1; // Servo IDs are 1-6 | |
| const motorName = motorNames[i]; | |
| results.push({ | |
| motor: motorId, | |
| name: motorName, | |
| status: "success", | |
| homing_offset: homingOffsets[motorName], | |
| range_min: rangeMins[motorName], | |
| range_max: rangeMaxes[motorName], | |
| range_size: rangeMaxes[motorName] - rangeMins[motorName], | |
| }); | |
| console.log( | |
| `✅ ${motorName} calibrated: range ${rangeMins[motorName]} to ${rangeMaxes[motorName]} (offset: ${homingOffsets[motorName]})` | |
| ); | |
| } | |
| console.log("\n🎉 Interactive calibration completed!"); | |
| return results; | |
| } | |
| /** | |
| * Verify calibration was successful | |
| * TODO: Implement proper verification with Feetech servo protocol | |
| */ | |
| private async verifyCalibration(): Promise<void> { | |
| console.log("Verifying calibration..."); | |
| // For now, just mock successful verification | |
| // Real implementation should check: | |
| // 1. All motors respond to ping | |
| // 2. Position limits are set correctly | |
| // 3. Homing offsets are applied | |
| // 4. Motors can move to test positions | |
| console.log("Calibration verification passed (mock)."); | |
| } | |
| /** | |
| * Prompt user for input (like Python's input() function) | |
| */ | |
| private async promptUser(message: string): Promise<string> { | |
| const rl = readline.createInterface({ | |
| input: process.stdin, | |
| output: process.stdout, | |
| }); | |
| return new Promise((resolve) => { | |
| rl.question(message, (answer) => { | |
| rl.close(); | |
| resolve(answer); | |
| }); | |
| }); | |
| } | |
| /** | |
| * Record homing offsets (current positions as center) | |
| * Mirrors Python bus.set_half_turn_homings() | |
| */ | |
| private async setHomingOffsets(): Promise<{ [motor: string]: number }> { | |
| console.log("Recording current positions as homing offsets..."); | |
| const currentPositions = await this.readMotorPositions(); | |
| const motorNames = [ | |
| "shoulder_pan", | |
| "shoulder_lift", | |
| "elbow_flex", | |
| "wrist_flex", | |
| "wrist_roll", | |
| "gripper", | |
| ]; | |
| const homingOffsets: { [motor: string]: number } = {}; | |
| for (let i = 0; i < motorNames.length; i++) { | |
| const motorName = motorNames[i]; | |
| const position = currentPositions[i]; | |
| // Calculate homing offset (half turn offset from current position) | |
| const maxRes = 4095; // STS3215 resolution | |
| homingOffsets[motorName] = position - Math.floor(maxRes / 2); | |
| console.log( | |
| ` ${motorName}: offset ${homingOffsets[motorName]} (current pos: ${position})` | |
| ); | |
| } | |
| return homingOffsets; | |
| } | |
| /** | |
| * Record ranges of motion by continuously reading positions | |
| * Mirrors Python bus.record_ranges_of_motion() | |
| */ | |
| private async recordRangesOfMotion(): Promise<{ | |
| rangeMins: { [motor: string]: number }; | |
| rangeMaxes: { [motor: string]: number }; | |
| }> { | |
| console.log("\n=== RECORDING RANGES OF MOTION ==="); | |
| console.log( | |
| "Move all joints sequentially through their entire ranges of motion." | |
| ); | |
| console.log( | |
| "Positions will be recorded continuously. Press ENTER to stop...\n" | |
| ); | |
| const motorNames = [ | |
| "shoulder_pan", | |
| "shoulder_lift", | |
| "elbow_flex", | |
| "wrist_flex", | |
| "wrist_roll", | |
| "gripper", | |
| ]; | |
| const rangeMins: { [motor: string]: number } = {}; | |
| const rangeMaxes: { [motor: string]: number } = {}; | |
| // Initialize with current positions | |
| const initialPositions = await this.readMotorPositions(); | |
| for (let i = 0; i < motorNames.length; i++) { | |
| const motorName = motorNames[i]; | |
| const position = initialPositions[i]; | |
| rangeMins[motorName] = position; | |
| rangeMaxes[motorName] = position; | |
| } | |
| let recording = true; | |
| let readCount = 0; | |
| // Set up readline to detect Enter key | |
| const rl = readline.createInterface({ | |
| input: process.stdin, | |
| output: process.stdout, | |
| }); | |
| rl.on("line", () => { | |
| recording = false; | |
| rl.close(); | |
| }); | |
| console.log("Recording started... (move the robot joints now)"); | |
| // Continuous recording loop | |
| while (recording) { | |
| try { | |
| const positions = await this.readMotorPositions(); | |
| readCount++; | |
| // Update min/max ranges | |
| for (let i = 0; i < motorNames.length; i++) { | |
| const motorName = motorNames[i]; | |
| const position = positions[i]; | |
| if (position < rangeMins[motorName]) { | |
| rangeMins[motorName] = position; | |
| } | |
| if (position > rangeMaxes[motorName]) { | |
| rangeMaxes[motorName] = position; | |
| } | |
| } | |
| // Show real-time feedback every 10 reads | |
| if (readCount % 10 === 0) { | |
| console.clear(); // Clear screen for live update | |
| console.log("=== LIVE POSITION RECORDING ==="); | |
| console.log(`Readings: ${readCount} | Press ENTER to stop\n`); | |
| console.log("Motor Name Current Min Max Range"); | |
| console.log("─".repeat(55)); | |
| for (let i = 0; i < motorNames.length; i++) { | |
| const motorName = motorNames[i]; | |
| const current = positions[i]; | |
| const min = rangeMins[motorName]; | |
| const max = rangeMaxes[motorName]; | |
| const range = max - min; | |
| console.log( | |
| `${motorName.padEnd(15)} ${current.toString().padStart(6)} ${min | |
| .toString() | |
| .padStart(6)} ${max.toString().padStart(6)} ${range | |
| .toString() | |
| .padStart(8)}` | |
| ); | |
| } | |
| console.log("\nMove joints through their full range..."); | |
| } | |
| // Small delay to avoid overwhelming the serial port | |
| await new Promise((resolve) => setTimeout(resolve, 50)); | |
| } catch (error) { | |
| console.warn( | |
| `Read error: ${error instanceof Error ? error.message : error}` | |
| ); | |
| await new Promise((resolve) => setTimeout(resolve, 100)); | |
| } | |
| } | |
| console.log(`\nRecording stopped after ${readCount} readings.`); | |
| console.log("\nFinal ranges recorded:"); | |
| for (const motorName of motorNames) { | |
| const min = rangeMins[motorName]; | |
| const max = rangeMaxes[motorName]; | |
| const range = max - min; | |
| console.log(` ${motorName}: ${min} to ${max} (range: ${range})`); | |
| } | |
| return { rangeMins, rangeMaxes }; | |
| } | |
| } | |
| /** | |
| * Factory function to create SO-100 follower robot | |
| * Mirrors Python's make_robot_from_config pattern | |
| */ | |
| export function createSO100Follower(config: RobotConfig): SO100Follower { | |
| return new SO100Follower(config); | |
| } | |