/** * Keyboard teleoperation controller for Node.js terminal * Handles raw keyboard input and robot position control using the keypress package. */ import * as readline from "readline"; import { SO100Follower } from "../robots/so100_follower.js"; /** * Keyboard controller for robot teleoperation * Handles terminal keyboard input and robot position updates */ export class KeyboardController { private robot: SO100Follower; private stepSize: number; private currentPositions: Record = {}; private motorNames = [ "shoulder_pan", "shoulder_lift", "elbow_flex", "wrist_flex", "wrist_roll", "gripper", ]; private running = false; private gripperState = false; // Toggle state for gripper constructor(robot: SO100Follower, stepSize: number = 25) { this.robot = robot; this.stepSize = stepSize; } /** * Start keyboard teleoperation * Sets up raw keyboard input and initializes robot positions */ async start(): Promise { console.log("Initializing keyboard controller..."); // Initialize current positions from robot try { this.currentPositions = await this.readRobotPositions(); } catch (error) { console.warn( "Could not read initial robot positions, using calibrated centers" ); // Initialize with calibrated center positions if available, otherwise use middle positions const calibratedLimits = this.robot.getCalibrationLimits(); this.motorNames.forEach((motor) => { const limits = calibratedLimits[motor]; const centerPosition = limits ? Math.floor((limits.min + limits.max) / 2) : 2047; this.currentPositions[motor] = centerPosition; }); } // Set up raw keyboard input this.setupKeyboardInput(); this.running = true; console.log("Keyboard controller ready. Use controls to move robot."); } /** * Stop keyboard teleoperation * Cleans up keyboard input handling */ async stop(): Promise { this.running = false; // Reset terminal to normal mode if (process.stdin.setRawMode) { process.stdin.setRawMode(false); } process.stdin.removeAllListeners("keypress"); console.log("Keyboard controller stopped."); } /** * Get current robot positions */ async getCurrentPositions(): Promise> { return { ...this.currentPositions }; } /** * Set up keyboard input handling * Uses readline for cross-platform keyboard input */ private setupKeyboardInput(): void { // Set up raw mode for immediate key response if (process.stdin.setRawMode) { process.stdin.setRawMode(true); } process.stdin.resume(); process.stdin.setEncoding("utf8"); // Handle keyboard input process.stdin.on("data", (key: string) => { if (!this.running) return; this.handleKeyPress(key); }); } /** * Handle individual key presses * Maps keys to robot motor movements */ private async handleKeyPress(key: string): Promise { let positionChanged = false; const newPositions = { ...this.currentPositions }; // Handle arrow keys first (they start with ESC but are multi-byte sequences) if (key.startsWith("\u001b[")) { const arrowKey = key.slice(2); switch (arrowKey) { case "A": // Up arrow newPositions.shoulder_lift += this.stepSize; positionChanged = true; break; case "B": // Down arrow newPositions.shoulder_lift -= this.stepSize; positionChanged = true; break; case "C": // Right arrow newPositions.shoulder_pan += this.stepSize; positionChanged = true; break; case "D": // Left arrow newPositions.shoulder_pan -= this.stepSize; positionChanged = true; break; } } else { // Handle single character keys const keyCode = key.charCodeAt(0); switch (keyCode) { // Standalone ESC key (emergency stop) case 27: if (key.length === 1) { console.log("\nšŸ›‘ EMERGENCY STOP!"); await this.emergencyStop(); return; } break; // Regular character keys case 119: // 'w' newPositions.elbow_flex += this.stepSize; positionChanged = true; break; case 115: // 's' newPositions.elbow_flex -= this.stepSize; positionChanged = true; break; case 97: // 'a' newPositions.wrist_flex -= this.stepSize; positionChanged = true; break; case 100: // 'd' newPositions.wrist_flex += this.stepSize; positionChanged = true; break; case 113: // 'q' newPositions.wrist_roll -= this.stepSize; positionChanged = true; break; case 101: // 'e' newPositions.wrist_roll += this.stepSize; positionChanged = true; break; case 32: // Space // Toggle gripper this.gripperState = !this.gripperState; newPositions.gripper = this.gripperState ? 2300 : 1800; positionChanged = true; break; // Ctrl+C case 3: console.log("\nExiting..."); process.exit(0); } } if (positionChanged) { // Apply position limits using calibration this.enforcePositionLimits(newPositions); // Update robot positions - only send changed motors for better performance try { await this.writeRobotPositions(newPositions); this.currentPositions = newPositions; } catch (error) { console.warn( `Failed to update robot positions: ${ error instanceof Error ? error.message : error }` ); } } } /** * Read current positions from robot * Uses SO100Follower position reading methods */ private async readRobotPositions(): Promise> { try { return await this.robot.getMotorPositions(); } catch (error) { console.warn( `Failed to read robot positions: ${ error instanceof Error ? error.message : error }` ); // Return default positions as fallback const positions: Record = {}; this.motorNames.forEach((motor, index) => { positions[motor] = 2047; // STS3215 middle position }); return positions; } } /** * Write positions to robot - optimized to only send changed motors * This was the key to the smooth performance in the working version */ private async writeRobotPositions( newPositions: Record ): Promise { // Only send commands for motors that actually changed const changedPositions: Record = {}; let hasChanges = false; for (const [motor, newPosition] of Object.entries(newPositions)) { if (Math.abs(this.currentPositions[motor] - newPosition) > 0.5) { changedPositions[motor] = newPosition; hasChanges = true; } } if (hasChanges) { await this.robot.setMotorPositions(changedPositions); } } /** * Enforce position limits based on calibration data * Uses actual calibrated limits instead of hardcoded defaults */ private enforcePositionLimits(positions: Record): void { // Get calibrated limits from robot const calibratedLimits = this.robot.getCalibrationLimits(); for (const [motor, position] of Object.entries(positions)) { const limits = calibratedLimits[motor]; if (limits) { positions[motor] = Math.max(limits.min, Math.min(limits.max, position)); } } } /** * Emergency stop - halt all robot movement */ private async emergencyStop(): Promise { try { // Stop all robot movement // TODO: Implement emergency stop in SO100Follower console.log("Emergency stop executed."); await this.stop(); process.exit(0); } catch (error) { console.error("Emergency stop failed:", error); process.exit(1); } } }