Spaces:
Running
Running
| # User Story 004: Keyboard Teleoperation | |
| ## Story | |
| **As a** robotics developer using SO-100 robot arms for testing and demonstrations | |
| **I want** to control my robot arm using keyboard keys in real-time | |
| **So that** I can manually operate the robot, test its movements, and demonstrate its capabilities without needing a second robot arm | |
| ## Background | |
| Keyboard teleoperation provides an immediate way to control robot arms for testing, demonstration, and manual operation. While the Python lerobot focuses primarily on leader-follower teleoperation, keyboard control is an essential feature for: | |
| - **Quick Testing**: Verify robot functionality and range of motion | |
| - **Demonstrations**: Show robot capabilities without complex setup | |
| - **Development**: Test robot behavior during development | |
| - **Troubleshooting**: Manually position robot for debugging | |
| - **Accessibility**: Control robots without specialized hardware | |
| The Python lerobot includes keyboard teleoperation capabilities within its teleoperator framework. We need to implement this as a standalone feature in lerobot.js, reusing our existing robot connection and calibration infrastructure from User Story 002. | |
| This will be a "quick win" implementation that: | |
| 1. Reuses existing SO-100 robot connection logic | |
| 2. Adds keyboard input handling (Node.js terminal + Web browser) | |
| 3. Provides real-time motor control within calibrated ranges | |
| 4. Shows live position feedback and performance metrics | |
| ## Acceptance Criteria | |
| ### Core Functionality | |
| - [ ] **Single Robot Control**: Connect to one SO-100 follower robot | |
| - [ ] **Keyboard Input**: Arrow keys, WASD, and other keys control robot motors | |
| - [ ] **Real-time Control**: Immediate response to keyboard input | |
| - [ ] **Position Limits**: Respect calibrated min/max ranges from calibration data | |
| - [ ] **Live Feedback**: Display current motor positions in real-time | |
| - [ ] **Graceful Shutdown**: Clean disconnection on ESC or Ctrl+C | |
| - [ ] **Cross-Platform**: Work on Windows, macOS, and Linux | |
| - [ ] **CLI Interface**: Provide `npx lerobot teleoperate` command | |
| ### User Experience | |
| - [ ] **Clear Controls**: Display control instructions (which keys do what) | |
| - [ ] **Live Position Display**: Real-time motor position values | |
| - [ ] **Performance Feedback**: Show control loop timing and responsiveness | |
| - [ ] **Error Handling**: Handle connection failures and invalid movements gracefully | |
| - [ ] **Emergency Stop**: ESC key immediately stops all movement | |
| - [ ] **Smooth Control**: Responsive and intuitive robot movement | |
| ### Technical Requirements | |
| - [ ] **Dual Platform**: Support both Node.js (CLI) and Web (browser) platforms | |
| - [ ] **Existing Robot Reuse**: Use existing SO-100 robot connection logic from calibration | |
| - [ ] **TypeScript**: Fully typed implementation following project conventions | |
| - [ ] **Configuration Integration**: Load and use calibration data for position limits | |
| - [ ] **Platform-Appropriate Input**: Terminal keyboard (Node.js) vs browser keyboard (Web) | |
| ## Expected User Flow | |
| ### Node.js CLI Keyboard Teleoperation | |
| ```bash | |
| # Simple keyboard control | |
| $ npx lerobot teleoperate \ | |
| --robot.type=so100_follower \ | |
| --robot.port=COM4 \ | |
| --robot.id=my_follower_arm \ | |
| --teleop.type=keyboard | |
| Connecting to robot: so100_follower on COM4 | |
| Robot connected successfully. | |
| Loading calibration: my_follower_arm | |
| Starting keyboard teleoperation... | |
| Controls: | |
| ββ Arrow Keys: Shoulder Lift | |
| ββ Arrow Keys: Shoulder Pan | |
| W/S: Elbow Flex | |
| A/D: Wrist Flex | |
| Q/E: Wrist Roll | |
| Space: Gripper Toggle | |
| ESC: Emergency Stop | |
| Ctrl+C: Exit | |
| Press any control key to begin... | |
| Current Positions: | |
| shoulder_pan: 2047 (range: 985-3085) | |
| shoulder_lift: 2047 (range: 1200-2800) | |
| elbow_flex: 2047 (range: 1000-3000) | |
| wrist_flex: 2047 (range: 1100-2900) | |
| wrist_roll: 2047 (range: 0-4095) | |
| gripper: 2047 (range: 1800-2300) | |
| Loop: 16.67ms (60 Hz) | Status: Connected | |
| ``` | |
| ### Web Browser Keyboard Teleoperation | |
| ```typescript | |
| // In a web application | |
| import { teleoperate } from "lerobot/web/teleoperate"; | |
| // Must be triggered by user interaction | |
| await teleoperate({ | |
| robot: { | |
| type: "so100_follower", | |
| id: "my_follower_arm", | |
| // port selected via browser dialog | |
| }, | |
| teleop: { | |
| type: "keyboard", | |
| }, | |
| }); | |
| // Browser shows modern teleoperation interface with: | |
| // - Live robot arm position visualization | |
| // - On-screen keyboard control instructions | |
| // - Real-time position values and ranges | |
| // - Emergency stop button | |
| // - Performance metrics | |
| ``` | |
| ### Advanced Usage | |
| ```bash | |
| # With custom control settings | |
| $ npx lerobot teleoperate \ | |
| --robot.type=so100_follower \ | |
| --robot.port=COM4 \ | |
| --robot.id=my_follower_arm \ | |
| --teleop.type=keyboard \ | |
| --step_size=50 \ | |
| --fps=30 | |
| # Different step sizes for finer/coarser control | |
| # Custom frame rates for different performance needs | |
| ``` | |
| ## Implementation Details | |
| ### File Structure | |
| ``` | |
| src/lerobot/ | |
| βββ node/ | |
| β βββ teleoperate.ts # Node.js keyboard teleoperation | |
| β βββ keyboard_teleop.ts # Node.js keyboard input handling | |
| β βββ robots/ | |
| β βββ so100_follower.ts # Extend existing robot for teleoperation | |
| βββ web/ | |
| βββ teleoperate.ts # Web keyboard teleoperation | |
| βββ keyboard_teleop.ts # Web keyboard input handling | |
| βββ robots/ | |
| βββ so100_follower.ts # Extend existing robot for teleoperation | |
| src/demo/ | |
| βββ components/ | |
| β βββ KeyboardTeleopInterface.tsx # Keyboard teleoperation interface | |
| β βββ RobotPositionDisplay.tsx # Live position visualization | |
| β βββ ControlInstructions.tsx # Keyboard control help | |
| β βββ PerformanceMonitor.tsx # Loop timing and metrics | |
| βββ pages/ | |
| βββ KeyboardTeleop.tsx # Keyboard teleoperation demo page | |
| src/cli/ | |
| βββ index.ts # CLI entry point (Node.js only) | |
| ``` | |
| ### Key Dependencies | |
| #### Node.js Platform | |
| - **keypress**: For raw keyboard input in terminal | |
| - **chalk**: For colored terminal output and status display | |
| - **Existing robot classes**: Reuse SO-100 connection logic from calibration | |
| #### Web Platform | |
| - **KeyboardEvent API**: Built-in browser keyboard handling | |
| - **Existing robot classes**: Reuse SO-100 connection logic from calibration | |
| - **React**: For demo interface components | |
| ### Core Functions to Implement | |
| #### Simplified Interface | |
| ```typescript | |
| // teleoperate.ts (simplified for keyboard-only) | |
| interface TeleoperateConfig { | |
| robot: RobotConfig; // Reuse from calibration work | |
| teleop: TeleoperatorConfig; // Teleoperator configuration | |
| step_size?: number; // Default: 25 (motor position units) | |
| fps?: number; // Default: 60 | |
| duration_s?: number | null; // Default: null (infinite) | |
| } | |
| interface TeleoperatorConfig { | |
| type: "keyboard"; // Only keyboard for now, expandable later | |
| } | |
| async function teleoperate(config: TeleoperateConfig): Promise<void>; | |
| // Keyboard control mappings | |
| interface KeyboardControls { | |
| shoulder_pan: { decrease: string; increase: string }; // left/right arrows | |
| shoulder_lift: { decrease: string; increase: string }; // down/up arrows | |
| elbow_flex: { decrease: string; increase: string }; // s/w | |
| wrist_flex: { decrease: string; increase: string }; // a/d | |
| wrist_roll: { decrease: string; increase: string }; // q/e | |
| gripper: { toggle: string }; // space | |
| emergency_stop: string; // esc | |
| } | |
| ``` | |
| #### Platform-Specific Keyboard Handling | |
| ```typescript | |
| // Node.js keyboard input | |
| class NodeKeyboardController { | |
| private currentPositions: Record<string, number> = {}; | |
| private robot: NodeSO100Follower; | |
| private stepSize: number; | |
| constructor(robot: NodeSO100Follower, stepSize: number = 25) { | |
| this.robot = robot; | |
| this.stepSize = stepSize; | |
| } | |
| async start(): Promise<void> { | |
| process.stdin.setRawMode(true); | |
| process.stdin.on("keypress", this.handleKeypress.bind(this)); | |
| // Initialize current positions from robot | |
| this.currentPositions = await this.robot.getPositions(); | |
| } | |
| private async handleKeypress(chunk: any, key: any): Promise<void> { | |
| let positionChanged = false; | |
| switch (key.name) { | |
| case "up": | |
| this.currentPositions.shoulder_lift += this.stepSize; | |
| positionChanged = true; | |
| break; | |
| case "down": | |
| this.currentPositions.shoulder_lift -= this.stepSize; | |
| positionChanged = true; | |
| break; | |
| case "left": | |
| this.currentPositions.shoulder_pan -= this.stepSize; | |
| positionChanged = true; | |
| break; | |
| case "right": | |
| this.currentPositions.shoulder_pan += this.stepSize; | |
| positionChanged = true; | |
| break; | |
| // ... other key mappings | |
| case "escape": | |
| await this.emergencyStop(); | |
| return; | |
| } | |
| if (positionChanged) { | |
| // Apply calibration limits | |
| this.enforcePositionLimits(); | |
| // Send to robot | |
| await this.robot.setPositions(this.currentPositions); | |
| } | |
| } | |
| } | |
| ``` | |
| ```typescript | |
| // Web keyboard input | |
| class WebKeyboardController { | |
| private currentPositions: Record<string, number> = {}; | |
| private robot: WebSO100Follower; | |
| private stepSize: number; | |
| private keysPressed: Set<string> = new Set(); | |
| constructor(robot: WebSO100Follower, stepSize: number = 25) { | |
| this.robot = robot; | |
| this.stepSize = stepSize; | |
| } | |
| async start(): Promise<void> { | |
| document.addEventListener("keydown", this.handleKeyDown.bind(this)); | |
| document.addEventListener("keyup", this.handleKeyUp.bind(this)); | |
| // Initialize current positions from robot | |
| this.currentPositions = await this.robot.getPositions(); | |
| // Start control loop for smooth movement | |
| this.startControlLoop(); | |
| } | |
| private handleKeyDown(event: KeyboardEvent): void { | |
| event.preventDefault(); | |
| this.keysPressed.add(event.code); | |
| if (event.code === "Escape") { | |
| this.emergencyStop(); | |
| } | |
| } | |
| private async startControlLoop(): Promise<void> { | |
| setInterval(async () => { | |
| let positionChanged = false; | |
| // Check all pressed keys and update positions | |
| if (this.keysPressed.has("ArrowUp")) { | |
| this.currentPositions.shoulder_lift += this.stepSize; | |
| positionChanged = true; | |
| } | |
| if (this.keysPressed.has("ArrowDown")) { | |
| this.currentPositions.shoulder_lift -= this.stepSize; | |
| positionChanged = true; | |
| } | |
| // ... other key checks | |
| if (positionChanged) { | |
| this.enforcePositionLimits(); | |
| await this.robot.setPositions(this.currentPositions); | |
| } | |
| }, 1000 / 60); // 60 FPS control loop | |
| } | |
| } | |
| ``` | |
| ### Technical Considerations | |
| #### Reusing Existing Robot Infrastructure | |
| ```typescript | |
| // Extend existing robot classes instead of reimplementing | |
| class TeleopSO100Follower extends SO100Follower { | |
| private calibrationData: CalibrationData; | |
| constructor(config: RobotConfig) { | |
| super(config); | |
| // Load calibration data from existing calibration system | |
| this.calibrationData = loadCalibrationData(config.id); | |
| } | |
| async getPositions(): Promise<Record<string, number>> { | |
| // Reuse existing position reading logic | |
| return await this.readCurrentPositions(); | |
| } | |
| async setPositions(positions: Record<string, number>): Promise<void> { | |
| // Reuse existing position writing logic with validation | |
| await this.writePositions(positions); | |
| } | |
| enforcePositionLimits( | |
| positions: Record<string, number> | |
| ): Record<string, number> { | |
| // Use calibration data to enforce limits | |
| for (const [motor, position] of Object.entries(positions)) { | |
| const limits = this.calibrationData[motor]; | |
| positions[motor] = Math.max( | |
| limits.range_min, | |
| Math.min(limits.range_max, position) | |
| ); | |
| } | |
| return positions; | |
| } | |
| } | |
| ``` | |
| #### Control Loop and Performance | |
| ```typescript | |
| // Simple control loop focused on keyboard input | |
| async function keyboardControlLoop( | |
| keyboardController: KeyboardController, | |
| robot: TeleopSO100Follower, | |
| fps: number = 60 | |
| ): Promise<void> { | |
| while (true) { | |
| const loopStart = performance.now(); | |
| // Keyboard controller handles input and robot updates internally | |
| // Just need to display current status | |
| const positions = await robot.getPositions(); | |
| displayPositions(positions); | |
| // Frame rate control | |
| const loopTime = performance.now() - loopStart; | |
| const targetLoopTime = 1000 / fps; | |
| const sleepTime = targetLoopTime - loopTime; | |
| if (sleepTime > 0) { | |
| await sleep(sleepTime); | |
| } | |
| displayPerformanceMetrics(loopTime, fps); | |
| } | |
| } | |
| ``` | |
| #### CLI Arguments (Simplified) | |
| ```typescript | |
| // CLI interface matching Python structure | |
| interface TeleoperateConfig { | |
| robot: { | |
| type: string; // "so100_follower" | |
| port: string; // COM port | |
| id?: string; // robot identifier | |
| }; | |
| teleop: { | |
| type: string; // "keyboard" | |
| }; | |
| step_size?: number; // position step size per keypress | |
| fps?: number; // control loop frame rate | |
| } | |
| // CLI parsing | |
| program | |
| .option("--robot.type <type>", "Robot type (so100_follower)") | |
| .option("--robot.port <port>", "Robot serial port") | |
| .option("--robot.id <id>", "Robot identifier") | |
| .option("--teleop.type <type>", "Teleoperator type (keyboard)") | |
| .option("--step_size <size>", "Position step size per keypress", "25") | |
| .option("--fps <fps>", "Control loop frame rate", "60"); | |
| ``` | |
| ## Definition of Done | |
| - [ ] **Functional**: Successfully controls SO-100 robot arm via keyboard input | |
| - [ ] **CLI Ready**: `npx lerobot teleoperate` provides keyboard control | |
| - [ ] **Intuitive Controls**: Arrow keys, WASD provide natural robot movement | |
| - [ ] **Web Compatible**: Browser-based keyboard teleoperation with modern interface | |
| - [ ] **Cross-Platform**: Node.js works on Windows, macOS, and Linux; Web works in Chromium browsers | |
| - [ ] **Safety Features**: Position limits, emergency stop, connection monitoring | |
| - [ ] **Real-time Feedback**: Live position display and performance metrics | |
| - [ ] **Integration**: Uses existing robot connection and calibration infrastructure | |
| - [ ] **Error Handling**: Graceful handling of connection failures and invalid movements | |
| - [ ] **Type Safe**: Full TypeScript coverage with strict mode for both implementations | |
| - [ ] **Quick Win**: Demonstrable keyboard robot control within minimal development time | |