Spaces:
Running
Running
| # User Story 005: Extensible Teleoperation Architecture | |
| ## Story | |
| **As a** robotics developer building teleoperation systems with various input devices | |
| **I want** to use different teleoperators (keyboard, leader arms, joysticks, VR controllers) to control my robot arms | |
| **So that** I can choose the most appropriate control method for my application without being locked into keyboard-only control | |
| ## Background | |
| The current Web teleoperation implementation has keyboard controls hardcoded into the `WebTeleoperationController`, making it impossible to use other input devices like leader arms, joysticks, or future control methods. Meanwhile, the Node.js implementation already has the correct extensible architecture with pluggable teleoperators. | |
| This architectural inconsistency creates several problems: | |
| - **Limited Extensibility**: Web users cannot use leader arms or other advanced teleoperators | |
| - **API Mismatch**: Web API `teleoperate(robotConnection, options)` differs from Node.js API `teleoperate(config: TeleoperateConfig)` | |
| - **Hardcoded Assumptions**: Keyboard logic is baked into the core teleoperation controller | |
| - **Future Limitations**: Adding new input devices requires core architecture changes | |
| The Python lerobot and Node.js lerobot.js both follow a proper separation where: | |
| - **Robots** handle motor communication and hardware control | |
| - **Teleoperators** handle input device reading and command generation | |
| - **Teleoperation orchestrator** connects teleoperators to robots | |
| We need to refactor the Web implementation to match this proven architecture, enabling seamless extension to leader arms, joysticks, VR controllers, and other future input devices. | |
| ## Acceptance Criteria | |
| ### Core Functionality | |
| - [ ] **Pluggable Teleoperators**: Support multiple teleoperator types (keyboard, leader arms, etc.) | |
| - [ ] **API Alignment**: Web API matches Node.js: `teleoperate(config: TeleoperateConfig)` | |
| - [ ] **Keyboard Teleoperator**: Extract existing keyboard logic into dedicated teleoperator class | |
| - [ ] **Teleoperator Abstraction**: Base interface that all teleoperators implement | |
| - [ ] **Type Safety**: Each teleoperator type has its own configuration interface | |
| - [ ] **State Management**: Maintain current TeleoperationState approach with teleoperator-specific extensions | |
| ### User Experience | |
| - [ ] **Breaking Change**: Clean API break - no backward compatibility with old hardcoded approach | |
| - [ ] **Consistent Interface**: Same teleoperation process object regardless of teleoperator type | |
| - [ ] **Future-Ready**: Easy addition of new teleoperator types without core changes | |
| - [ ] **Error Handling**: Clear error messages for unsupported or misconfigured teleoperators | |
| ### Technical Requirements | |
| - [ ] **Architecture Separation**: Clear separation between robot control and teleoperator input | |
| - [ ] **Web Implementation**: Focus on Web platform to match Node.js architecture | |
| - [ ] **TypeScript**: Fully typed with union types for teleoperator configurations | |
| - [ ] **No Code Duplication**: Reuse existing motor communication and robot control logic | |
| - [ ] **Configuration-Driven**: Teleoperator behavior determined by config, not hardcoded logic | |
| ## Expected User Flow | |
| ### Keyboard Teleoperation (Current Functionality Preserved) | |
| ```typescript | |
| import { teleoperate } from "lerobot/web/teleoperate"; | |
| // New API - explicitly specify teleoperator | |
| const teleoperationProcess = await teleoperate({ | |
| robot: { | |
| type: "so100_follower", | |
| port: selectedPort, | |
| // ... existing robot config | |
| }, | |
| teleop: { | |
| type: "keyboard", | |
| stepSize: 25, | |
| updateRate: 60, | |
| }, | |
| calibrationData: loadedCalibrationData, | |
| onStateUpdate: (state) => { | |
| // Update UI with current state | |
| console.log("Robot positions:", state.motorConfigs); | |
| console.log("Active keys:", state.keyStates); | |
| }, | |
| }); | |
| // Same process interface as before | |
| teleoperationProcess.start(); | |
| teleoperationProcess.updateKeyState("ArrowUp", true); | |
| teleoperationProcess.stop(); | |
| ``` | |
| ### Leader Arm Teleoperation (Future) | |
| ```typescript | |
| // Future leader arm teleoperator | |
| const teleoperationProcess = await teleoperate({ | |
| robot: { | |
| type: "so100_follower", | |
| port: followerPort, | |
| }, | |
| teleop: { | |
| type: "so100_leader", | |
| port: leaderPort, | |
| calibrationData: leaderCalibration, | |
| positionSmoothing: true, | |
| }, | |
| calibrationData: followerCalibration, | |
| onStateUpdate: (state) => { | |
| console.log("Follower positions:", state.motorConfigs); | |
| console.log("Leader positions:", state.leaderPositions); | |
| }, | |
| }); | |
| teleoperationProcess.start(); // Reads from leader, writes to follower | |
| ``` | |
| ### Direct Motor Control | |
| ```typescript | |
| import { teleoperate, DirectTeleoperator } from "@lerobot/web"; | |
| // Direct motor control for programmatic use (sliders, API calls) | |
| const teleoperationProcess = await teleoperate({ | |
| robot: { | |
| type: "so100_follower", | |
| port: robotPort, | |
| }, | |
| teleop: { | |
| type: "direct", | |
| }, | |
| calibrationData: calibrationData, | |
| onStateUpdate: (state) => { | |
| console.log("Robot positions:", state.motorConfigs); | |
| }, | |
| }); | |
| // Control motors programmatically | |
| teleoperationProcess.start(); | |
| const directTeleoperator = | |
| teleoperationProcess.teleoperator as DirectTeleoperator; | |
| await directTeleoperator.moveMotor("shoulder_pan", 2500); | |
| await directTeleoperator.setMotorPositions({ | |
| shoulder_lift: 1800, | |
| gripper: 3000, | |
| }); | |
| ``` | |
| ### Joystick Teleoperation (Future) | |
| ```typescript | |
| // Future joystick teleoperator | |
| const teleoperationProcess = await teleoperate({ | |
| robot: { | |
| type: "so100_follower", | |
| port: robotPort, | |
| }, | |
| teleop: { | |
| type: "gamepad", | |
| controllerIndex: 0, | |
| axisMapping: { | |
| leftStick: "shoulder_pan", | |
| rightStick: "shoulder_lift", | |
| triggers: "gripper", | |
| }, | |
| }, | |
| calibrationData: calibrationData, | |
| }); | |
| ``` | |
| ### Error Handling | |
| ```typescript | |
| // Unsupported teleoperator type | |
| try { | |
| await teleoperate({ | |
| robot: { type: "so100_follower", port: "COM4" }, | |
| teleop: { type: "unsupported_device" }, | |
| }); | |
| } catch (error) { | |
| console.error("Error: Unsupported teleoperator type: unsupported_device"); | |
| console.error("Supported types: keyboard, so100_leader"); | |
| } | |
| ``` | |
| ## Implementation Details | |
| ### File Structure | |
| ``` | |
| packages/web/src/ | |
| βββ teleoperate.ts # Updated main API (breaking change) | |
| βββ teleoperators/ | |
| β βββ base-teleoperator.ts # Base teleoperator interface | |
| β βββ keyboard-teleoperator.ts # Extracted keyboard logic | |
| β βββ index.ts # Barrel exports | |
| β βββ [future] | |
| β βββ leader-arm-teleoperator.ts | |
| β βββ gamepad-teleoperator.ts | |
| β βββ vr-teleoperator.ts | |
| βββ types/ | |
| βββ teleoperation.ts # Updated with teleoperator config types | |
| ``` | |
| ### Key Dependencies | |
| #### No New Dependencies | |
| - **Existing**: Reuse all current Web dependencies (Web Serial API, motor communication utils) | |
| - **Architecture Only**: This is purely an architectural refactor - no new external dependencies | |
| ### Core Functions to Implement | |
| #### Updated API (Breaking Change) | |
| ```typescript | |
| // teleoperate.ts - New API matching Node.js | |
| interface TeleoperateConfig { | |
| robot: RobotConnection; | |
| teleop: TeleoperatorConfig; | |
| calibrationData?: { [motorName: string]: any }; | |
| onStateUpdate?: (state: TeleoperationState) => void; | |
| } | |
| // Union type for all teleoperator configurations | |
| type TeleoperatorConfig = | |
| | KeyboardTeleoperatorConfig | |
| | LeaderArmTeleoperatorConfig | |
| | GamepadTeleoperatorConfig; | |
| // Main function - breaking change from old API | |
| async function teleoperate( | |
| config: TeleoperateConfig | |
| ): Promise<TeleoperationProcess>; | |
| ``` | |
| #### Teleoperator Configuration Types | |
| ```typescript | |
| // Base interface all teleoperators implement | |
| interface BaseTeleoperatorConfig { | |
| type: string; | |
| } | |
| // Keyboard teleoperator configuration | |
| interface KeyboardTeleoperatorConfig extends BaseTeleoperatorConfig { | |
| type: "keyboard"; | |
| stepSize?: number; // Default: 25 | |
| updateRate?: number; // Default: 60 (FPS) | |
| keyTimeout?: number; // Default: 10000ms | |
| } | |
| // Future: Leader arm teleoperator configuration | |
| interface LeaderArmTeleoperatorConfig extends BaseTeleoperatorConfig { | |
| type: "so100_leader"; | |
| port: string; | |
| calibrationData?: any; | |
| positionSmoothing?: boolean; | |
| scaleFactor?: number; | |
| } | |
| // Future: Gamepad teleoperator configuration | |
| interface GamepadTeleoperatorConfig extends BaseTeleoperatorConfig { | |
| type: "gamepad"; | |
| controllerIndex?: number; | |
| axisMapping?: { [axis: string]: string }; | |
| deadzone?: number; | |
| } | |
| ``` | |
| #### Base Teleoperator Interface | |
| ```typescript | |
| // base-teleoperator.ts | |
| interface WebTeleoperator { | |
| // Lifecycle management | |
| initialize(robotConnection: RobotConnection): Promise<void>; | |
| start(): void; | |
| stop(): void; | |
| disconnect(): Promise<void>; | |
| // State management | |
| getState(): TeleoperatorSpecificState; | |
| // Robot interaction | |
| onMotorConfigsUpdate(motorConfigs: MotorConfig[]): void; | |
| } | |
| // Base class with common functionality | |
| abstract class BaseWebTeleoperator implements WebTeleoperator { | |
| protected port: MotorCommunicationPort; | |
| protected motorConfigs: MotorConfig[] = []; | |
| protected isActive: boolean = false; | |
| protected onStateUpdate?: (state: TeleoperationState) => void; | |
| constructor( | |
| port: MotorCommunicationPort, | |
| motorConfigs: MotorConfig[], | |
| onStateUpdate?: (state: TeleoperationState) => void | |
| ) { | |
| this.port = port; | |
| this.motorConfigs = motorConfigs; | |
| this.onStateUpdate = onStateUpdate; | |
| } | |
| abstract initialize(): Promise<void>; | |
| abstract start(): void; | |
| abstract stop(): void; | |
| abstract getState(): TeleoperatorSpecificState; | |
| async disconnect(): Promise<void> { | |
| this.stop(); | |
| } | |
| onMotorConfigsUpdate(motorConfigs: MotorConfig[]): void { | |
| this.motorConfigs = motorConfigs; | |
| } | |
| } | |
| ``` | |
| #### Keyboard Teleoperator (Extracted Logic) | |
| ```typescript | |
| // keyboard-teleoperator.ts - Extract from current WebTeleoperationController | |
| class KeyboardTeleoperator extends BaseWebTeleoperator { | |
| private keyboardControls: { [key: string]: KeyboardControl } = {}; | |
| private updateInterval: NodeJS.Timeout | null = null; | |
| private keyStates: { | |
| [key: string]: { pressed: boolean; timestamp: number }; | |
| } = {}; | |
| // Configuration from KeyboardTeleoperatorConfig | |
| private readonly stepSize: number; | |
| private readonly updateRate: number; | |
| private readonly keyTimeout: number; | |
| constructor( | |
| config: KeyboardTeleoperatorConfig, | |
| port: MotorCommunicationPort, | |
| motorConfigs: MotorConfig[], | |
| keyboardControls: { [key: string]: KeyboardControl }, | |
| onStateUpdate?: (state: TeleoperationState) => void | |
| ) { | |
| super(port, motorConfigs, onStateUpdate); | |
| this.keyboardControls = keyboardControls; | |
| // Extract configuration | |
| this.stepSize = config.stepSize ?? 25; | |
| this.updateRate = config.updateRate ?? 60; | |
| this.keyTimeout = config.keyTimeout ?? 10000; | |
| } | |
| async initialize(): Promise<void> { | |
| // Move existing initialization logic here | |
| for (const config of this.motorConfigs) { | |
| const position = await readMotorPosition(this.port, config.id); | |
| if (position !== null) { | |
| config.currentPosition = position; | |
| } | |
| } | |
| } | |
| start(): void { | |
| // Move existing start logic here | |
| if (this.isActive) return; | |
| this.isActive = true; | |
| this.updateInterval = setInterval(() => { | |
| this.updateMotorPositions(); | |
| }, 1000 / this.updateRate); | |
| } | |
| stop(): void { | |
| // Move existing stop logic here | |
| if (!this.isActive) return; | |
| this.isActive = false; | |
| if (this.updateInterval) { | |
| clearInterval(this.updateInterval); | |
| this.updateInterval = null; | |
| } | |
| this.keyStates = {}; | |
| if (this.onStateUpdate) { | |
| this.onStateUpdate(this.buildTeleoperationState()); | |
| } | |
| } | |
| updateKeyState(key: string, pressed: boolean): void { | |
| this.keyStates[key] = { pressed, timestamp: Date.now() }; | |
| } | |
| getState(): KeyboardTeleoperatorState { | |
| return { | |
| keyStates: { ...this.keyStates }, | |
| }; | |
| } | |
| private buildTeleoperationState(): TeleoperationState { | |
| return { | |
| isActive: this.isActive, | |
| motorConfigs: [...this.motorConfigs], | |
| lastUpdate: Date.now(), | |
| keyStates: { ...this.keyStates }, | |
| }; | |
| } | |
| private updateMotorPositions(): void { | |
| // Move existing updateMotorPositions logic here | |
| // ... (existing keyboard processing logic) | |
| } | |
| } | |
| ``` | |
| #### Teleoperator Factory | |
| ```typescript | |
| // teleoperate.ts - Factory pattern | |
| async function createTeleoperator( | |
| config: TeleoperateConfig, | |
| port: MotorCommunicationPort, | |
| motorConfigs: MotorConfig[], | |
| robotHardwareConfig: RobotHardwareConfig | |
| ): Promise<WebTeleoperator> { | |
| switch (config.teleop.type) { | |
| case "keyboard": | |
| return new KeyboardTeleoperator( | |
| config.teleop, | |
| port, | |
| motorConfigs, | |
| robotHardwareConfig.keyboardControls, | |
| config.onStateUpdate | |
| ); | |
| case "so100_leader": | |
| // Future implementation | |
| throw new Error("Leader arm teleoperator not yet implemented"); | |
| case "gamepad": | |
| // Future implementation | |
| throw new Error("Gamepad teleoperator not yet implemented"); | |
| default: | |
| throw new Error( | |
| `Unsupported teleoperator type: ${(config.teleop as any).type}` | |
| ); | |
| } | |
| } | |
| ``` | |
| #### Updated Main Teleoperate Function | |
| ```typescript | |
| // teleoperate.ts - Updated main function (breaking change) | |
| export async function teleoperate( | |
| config: TeleoperateConfig | |
| ): Promise<TeleoperationProcess> { | |
| // Validate required fields | |
| if (!config.robot.robotType) { | |
| throw new Error( | |
| "Robot type is required for teleoperation. Please configure the robot first." | |
| ); | |
| } | |
| // Create web serial port wrapper (same as before) | |
| const port = new WebSerialPortWrapper(config.robot.port); | |
| await port.initialize(); | |
| // Get robot-specific configuration (same as before) | |
| let robotHardwareConfig: RobotHardwareConfig; | |
| if (config.robot.robotType.startsWith("so100")) { | |
| robotHardwareConfig = createSO100Config(config.robot.robotType); | |
| } else { | |
| throw new Error(`Unsupported robot type: ${config.robot.robotType}`); | |
| } | |
| // Create motor configs (same as before) | |
| const defaultMotorConfigs = | |
| createMotorConfigsFromRobotConfig(robotHardwareConfig); | |
| const motorConfigs = config.calibrationData | |
| ? applyCalibrationToMotorConfigs( | |
| defaultMotorConfigs, | |
| config.calibrationData | |
| ) | |
| : defaultMotorConfigs; | |
| // Create teleoperator using factory pattern (NEW) | |
| const teleoperator = await createTeleoperator( | |
| config, | |
| port, | |
| motorConfigs, | |
| robotHardwareConfig | |
| ); | |
| await teleoperator.initialize(); | |
| // Return process object (same interface as before) | |
| return { | |
| start: () => { | |
| teleoperator.start(); | |
| // State update loop (same as before) | |
| if (config.onStateUpdate) { | |
| const updateLoop = () => { | |
| if (teleoperator.getState()) { | |
| config.onStateUpdate!( | |
| buildTeleoperationStateFromTeleoperator(teleoperator) | |
| ); | |
| setTimeout(updateLoop, 100); | |
| } | |
| }; | |
| updateLoop(); | |
| } | |
| }, | |
| stop: () => teleoperator.stop(), | |
| updateKeyState: (key: string, pressed: boolean) => { | |
| // Delegate to teleoperator if it supports keyboard input | |
| if (teleoperator instanceof KeyboardTeleoperator) { | |
| teleoperator.updateKeyState(key, pressed); | |
| } | |
| }, | |
| getState: () => buildTeleoperationStateFromTeleoperator(teleoperator), | |
| moveMotor: async (motorName: string, position: number) => { | |
| // Direct motor control through teleoperator | |
| if (teleoperator instanceof DirectTeleoperator) { | |
| return teleoperator.moveMotor(motorName, position); | |
| } | |
| throw new Error( | |
| `Motor control not supported for ${config.teleop.type} teleoperator` | |
| ); | |
| }, | |
| setMotorPositions: async (positions: { [motorName: string]: number }) => { | |
| // Direct motor control through teleoperator | |
| if (teleoperator instanceof DirectTeleoperator) { | |
| return teleoperator.setMotorPositions(positions); | |
| } | |
| throw new Error( | |
| `Motor control not supported for ${config.teleop.type} teleoperator` | |
| ); | |
| }, | |
| disconnect: () => teleoperator.disconnect(), | |
| }; | |
| } | |
| ``` | |
| ### Technical Considerations | |
| #### State Management Strategy | |
| Maintain current `TeleoperationState` structure but extend with teleoperator-specific state: | |
| ```typescript | |
| interface TeleoperationState { | |
| isActive: boolean; | |
| motorConfigs: MotorConfig[]; | |
| lastUpdate: number; | |
| // Teleoperator-specific state (optional fields for different types) | |
| keyStates?: { [key: string]: { pressed: boolean; timestamp: number } }; // keyboard | |
| leaderPositions?: { [motor: string]: number }; // leader arm | |
| gamepadState?: { axes: number[]; buttons: boolean[] }; // gamepad | |
| } | |
| ``` | |
| #### Migration Strategy | |
| **Breaking Change Approach:** | |
| 1. **Remove old API** - No backward compatibility | |
| 2. **Update examples** - All demo applications must be updated to use new API | |
| 3. **Clear documentation** - Document the API change and migration path | |
| 4. **Type safety** - TypeScript will catch all usages of old API | |
| #### Future Extensibility | |
| The architecture supports easy addition of new teleoperators: | |
| ```typescript | |
| // Future: Add VR controller | |
| interface VRTeleoperatorConfig extends BaseTeleoperatorConfig { | |
| type: "vr_controller"; | |
| handedness: "left" | "right"; | |
| trackingSpace: "local" | "world"; | |
| } | |
| class VRTeleoperator extends BaseWebTeleoperator { | |
| // VR-specific implementation | |
| } | |
| // Add to factory in teleoperate.ts | |
| case "vr_controller": | |
| return new VRTeleoperator(config.teleop, port, motorConfigs, config.onStateUpdate); | |
| ``` | |
| #### Performance Considerations | |
| - **Same Performance**: No performance impact - just architectural refactoring | |
| - **Memory Usage**: Slightly lower memory usage due to cleaner separation | |
| - **Extensibility**: No overhead for unused teleoperator types | |
| ## Definition of Done | |
| - [ ] **API Breaking Change**: Web API updated to `teleoperate(config: TeleoperateConfig)` matching Node.js | |
| - [ ] **Keyboard Teleoperator**: Existing keyboard functionality extracted into `KeyboardTeleoperator` class | |
| - [ ] **Base Teleoperator**: `BaseWebTeleoperator` abstract class with common functionality | |
| - [ ] **Teleoperator Factory**: Factory pattern for creating appropriate teleoperator instances | |
| - [ ] **Type Safety**: Full TypeScript coverage with union types for teleoperator configurations | |
| - [ ] **State Management**: Current `TeleoperationState` approach preserved with teleoperator extensions | |
| - [ ] **Process Interface**: `TeleoperationProcess` interface remains the same for existing UI code | |
| - [ ] **Error Handling**: Clear error messages for unsupported teleoperator types | |
| - [ ] **No Regression**: Keyboard teleoperation functionality identical to current implementation | |
| - [ ] **Future Ready**: Architecture supports easy addition of leader arms, joysticks, VR controllers | |
| - [ ] **Code Quality**: No code duplication, clean separation of concerns | |
| - [ ] **Documentation**: Updated examples and documentation for new API | |