NERDDISCO commited on
Commit
ca45f6b
·
1 Parent(s): 737c5f6

feat: teleoperate in node

Browse files
docs/conventions.md CHANGED
@@ -557,3 +557,81 @@ const STS3215_REGISTERS = {
557
  5. **Skipping Intermediate Delays**: Not waiting for motor register writes to take effect → inconsistent state
558
 
559
  **This sequence debugging took extensive analysis to solve. Future implementations MUST follow this exact pattern to maintain Python compatibility.**
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
557
  5. **Skipping Intermediate Delays**: Not waiting for motor register writes to take effect → inconsistent state
558
 
559
  **This sequence debugging took extensive analysis to solve. Future implementations MUST follow this exact pattern to maintain Python compatibility.**
560
+
561
+ #### CRITICAL: Smooth Motor Control Recipe (PROVEN WORKING)
562
+
563
+ **These patterns provide buttery-smooth, responsive motor control. Deviating from this recipe causes stuttering, lag, or poor responsiveness.**
564
+
565
+ ##### 🚀 Performance Optimizations (KEEP THESE!)
566
+
567
+ **1. Optimal Step Size**
568
+
569
+ - **✅ PERFECT**: `25` units per keypress (responsive but not jumpy)
570
+ - **❌ WRONG**: `5` units (too sluggish) or `100` units (too aggressive)
571
+
572
+ **2. Minimal Motor Communication Delay**
573
+
574
+ - **✅ PERFECT**: `1ms` delay between motor commands
575
+ - **❌ WRONG**: `5ms+` delays cause stuttering
576
+
577
+ **3. Smart Motor Updates (CRITICAL FOR SMOOTHNESS)**
578
+
579
+ - **✅ PERFECT**: Only send commands for motors that actually changed
580
+ - **✅ PERFECT**: Use `0.5` unit threshold to detect meaningful changes
581
+ - **❌ WRONG**: Send ALL motor positions every time (causes serial bus conflicts)
582
+
583
+ **4. Change Detection Threshold**
584
+
585
+ - **✅ PERFECT**: `0.5` units prevents micro-movements and unnecessary commands
586
+ - **❌ WRONG**: `0.1` units (too sensitive) or no threshold (constant spam)
587
+
588
+ ##### 🎯 Teleoperation Loop Best Practices
589
+
590
+ **1. Eliminate Display Spam**
591
+
592
+ - **✅ PERFECT**: Minimal loop with just duration checks and 100ms delay
593
+ - **❌ WRONG**: Constant position reading and display updates (causes 90ms+ lag)
594
+
595
+ **2. Event-Driven Keyboard Input**
596
+
597
+ - **✅ PERFECT**: Use `process.stdin.on("data")` for immediate response
598
+ - **❌ WRONG**: Polling-based input with timers (adds delay)
599
+
600
+ ##### 🔧 Hardware Communication Patterns
601
+
602
+ **1. Discrete Step-Based Control**
603
+
604
+ - **✅ PERFECT**: Immediate position updates on keypress
605
+ - **❌ WRONG**: Continuous/velocity-based control (causes complexity and lag)
606
+
607
+ **2. Direct Motor Position Writing**
608
+
609
+ - **✅ PERFECT**: Simple, immediate motor updates with position limits
610
+ - **❌ WRONG**: Complex interpolation, target positions, multiple update cycles
611
+
612
+ ##### 🎮 Proven Working Values
613
+
614
+ **Key Configuration Values:**
615
+
616
+ - `stepSize = 25` (default in teleoperate.ts and keyboard_teleop.ts)
617
+ - `1ms` motor communication delay (so100_follower.ts)
618
+ - `0.5` unit change detection threshold
619
+ - `100ms` teleoperation loop delay
620
+
621
+ ##### ⚠️ Performance Killers (NEVER DO THESE)
622
+
623
+ 1. **❌ Display Updates in Main Loop**: Causes 90ms+ loop times
624
+ 2. **❌ Continuous/Velocity Control**: Adds complexity without benefit for keyboard input
625
+ 3. **❌ All-Motor Updates**: Sends unnecessary commands, overwhelms serial bus
626
+ 4. **❌ Long Communication Delays**: 5ms+ delays cause stuttering
627
+ 5. **❌ Complex Interpolation**: Adds latency for simple step-based control
628
+ 6. **❌ No Change Detection**: Spams motors with identical positions
629
+
630
+ ##### 📊 Performance Metrics (When It's Working Right)
631
+
632
+ - **Keypress Response**: Immediate (< 10ms)
633
+ - **Motor Update**: Single command per changed motor
634
+ - **Loop Time**: < 5ms (when not reading positions)
635
+ - **User Experience**: "Buttery smooth", "fucking working and super perfect"
636
+
637
+ **Golden Rule**: When you achieve smooth control, NEVER change the step size, delays, or update patterns without extensive testing. These values were optimized through real hardware testing.\*\*
docs/planning/004_teleoperate.md ADDED
@@ -0,0 +1,442 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # User Story 004: Keyboard Teleoperation
2
+
3
+ ## Story
4
+
5
+ **As a** robotics developer using SO-100 robot arms for testing and demonstrations
6
+ **I want** to control my robot arm using keyboard keys in real-time
7
+ **So that** I can manually operate the robot, test its movements, and demonstrate its capabilities without needing a second robot arm
8
+
9
+ ## Background
10
+
11
+ 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:
12
+
13
+ - **Quick Testing**: Verify robot functionality and range of motion
14
+ - **Demonstrations**: Show robot capabilities without complex setup
15
+ - **Development**: Test robot behavior during development
16
+ - **Troubleshooting**: Manually position robot for debugging
17
+ - **Accessibility**: Control robots without specialized hardware
18
+
19
+ 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.
20
+
21
+ This will be a "quick win" implementation that:
22
+
23
+ 1. Reuses existing SO-100 robot connection logic
24
+ 2. Adds keyboard input handling (Node.js terminal + Web browser)
25
+ 3. Provides real-time motor control within calibrated ranges
26
+ 4. Shows live position feedback and performance metrics
27
+
28
+ ## Acceptance Criteria
29
+
30
+ ### Core Functionality
31
+
32
+ - [ ] **Single Robot Control**: Connect to one SO-100 follower robot
33
+ - [ ] **Keyboard Input**: Arrow keys, WASD, and other keys control robot motors
34
+ - [ ] **Real-time Control**: Immediate response to keyboard input
35
+ - [ ] **Position Limits**: Respect calibrated min/max ranges from calibration data
36
+ - [ ] **Live Feedback**: Display current motor positions in real-time
37
+ - [ ] **Graceful Shutdown**: Clean disconnection on ESC or Ctrl+C
38
+ - [ ] **Cross-Platform**: Work on Windows, macOS, and Linux
39
+ - [ ] **CLI Interface**: Provide `npx lerobot teleoperate` command
40
+
41
+ ### User Experience
42
+
43
+ - [ ] **Clear Controls**: Display control instructions (which keys do what)
44
+ - [ ] **Live Position Display**: Real-time motor position values
45
+ - [ ] **Performance Feedback**: Show control loop timing and responsiveness
46
+ - [ ] **Error Handling**: Handle connection failures and invalid movements gracefully
47
+ - [ ] **Emergency Stop**: ESC key immediately stops all movement
48
+ - [ ] **Smooth Control**: Responsive and intuitive robot movement
49
+
50
+ ### Technical Requirements
51
+
52
+ - [ ] **Dual Platform**: Support both Node.js (CLI) and Web (browser) platforms
53
+ - [ ] **Existing Robot Reuse**: Use existing SO-100 robot connection logic from calibration
54
+ - [ ] **TypeScript**: Fully typed implementation following project conventions
55
+ - [ ] **Configuration Integration**: Load and use calibration data for position limits
56
+ - [ ] **Platform-Appropriate Input**: Terminal keyboard (Node.js) vs browser keyboard (Web)
57
+
58
+ ## Expected User Flow
59
+
60
+ ### Node.js CLI Keyboard Teleoperation
61
+
62
+ ```bash
63
+ # Simple keyboard control
64
+ $ npx lerobot teleoperate \
65
+ --robot.type=so100_follower \
66
+ --robot.port=COM4 \
67
+ --robot.id=my_follower_arm \
68
+ --teleop.type=keyboard
69
+
70
+ Connecting to robot: so100_follower on COM4
71
+ Robot connected successfully.
72
+ Loading calibration: my_follower_arm
73
+
74
+ Starting keyboard teleoperation...
75
+ Controls:
76
+ ↑↓ Arrow Keys: Shoulder Lift
77
+ ←→ Arrow Keys: Shoulder Pan
78
+ W/S: Elbow Flex
79
+ A/D: Wrist Flex
80
+ Q/E: Wrist Roll
81
+ Space: Gripper Toggle
82
+ ESC: Emergency Stop
83
+ Ctrl+C: Exit
84
+
85
+ Press any control key to begin...
86
+
87
+ Current Positions:
88
+ shoulder_pan: 2047 (range: 985-3085)
89
+ shoulder_lift: 2047 (range: 1200-2800)
90
+ elbow_flex: 2047 (range: 1000-3000)
91
+ wrist_flex: 2047 (range: 1100-2900)
92
+ wrist_roll: 2047 (range: 0-4095)
93
+ gripper: 2047 (range: 1800-2300)
94
+
95
+ Loop: 16.67ms (60 Hz) | Status: Connected
96
+ ```
97
+
98
+ ### Web Browser Keyboard Teleoperation
99
+
100
+ ```typescript
101
+ // In a web application
102
+ import { teleoperate } from "lerobot/web/teleoperate";
103
+
104
+ // Must be triggered by user interaction
105
+ await teleoperate({
106
+ robot: {
107
+ type: "so100_follower",
108
+ id: "my_follower_arm",
109
+ // port selected via browser dialog
110
+ },
111
+ teleop: {
112
+ type: "keyboard",
113
+ },
114
+ });
115
+
116
+ // Browser shows modern teleoperation interface with:
117
+ // - Live robot arm position visualization
118
+ // - On-screen keyboard control instructions
119
+ // - Real-time position values and ranges
120
+ // - Emergency stop button
121
+ // - Performance metrics
122
+ ```
123
+
124
+ ### Advanced Usage
125
+
126
+ ```bash
127
+ # With custom control settings
128
+ $ npx lerobot teleoperate \
129
+ --robot.type=so100_follower \
130
+ --robot.port=COM4 \
131
+ --robot.id=my_follower_arm \
132
+ --teleop.type=keyboard \
133
+ --step_size=50 \
134
+ --fps=30
135
+
136
+ # Different step sizes for finer/coarser control
137
+ # Custom frame rates for different performance needs
138
+ ```
139
+
140
+ ## Implementation Details
141
+
142
+ ### File Structure
143
+
144
+ ```
145
+ src/lerobot/
146
+ ├── node/
147
+ │ ├── teleoperate.ts # Node.js keyboard teleoperation
148
+ │ ├── keyboard_teleop.ts # Node.js keyboard input handling
149
+ │ └── robots/
150
+ │ └── so100_follower.ts # Extend existing robot for teleoperation
151
+ └── web/
152
+ ├── teleoperate.ts # Web keyboard teleoperation
153
+ ├── keyboard_teleop.ts # Web keyboard input handling
154
+ └── robots/
155
+ └── so100_follower.ts # Extend existing robot for teleoperation
156
+
157
+ src/demo/
158
+ ├── components/
159
+ │ ├── KeyboardTeleopInterface.tsx # Keyboard teleoperation interface
160
+ │ ├── RobotPositionDisplay.tsx # Live position visualization
161
+ │ ├── ControlInstructions.tsx # Keyboard control help
162
+ │ └── PerformanceMonitor.tsx # Loop timing and metrics
163
+ └── pages/
164
+ └── KeyboardTeleop.tsx # Keyboard teleoperation demo page
165
+
166
+ src/cli/
167
+ └── index.ts # CLI entry point (Node.js only)
168
+ ```
169
+
170
+ ### Key Dependencies
171
+
172
+ #### Node.js Platform
173
+
174
+ - **keypress**: For raw keyboard input in terminal
175
+ - **chalk**: For colored terminal output and status display
176
+ - **Existing robot classes**: Reuse SO-100 connection logic from calibration
177
+
178
+ #### Web Platform
179
+
180
+ - **KeyboardEvent API**: Built-in browser keyboard handling
181
+ - **Existing robot classes**: Reuse SO-100 connection logic from calibration
182
+ - **React**: For demo interface components
183
+
184
+ ### Core Functions to Implement
185
+
186
+ #### Simplified Interface
187
+
188
+ ```typescript
189
+ // teleoperate.ts (simplified for keyboard-only)
190
+ interface TeleoperateConfig {
191
+ robot: RobotConfig; // Reuse from calibration work
192
+ teleop: TeleoperatorConfig; // Teleoperator configuration
193
+ step_size?: number; // Default: 25 (motor position units)
194
+ fps?: number; // Default: 60
195
+ duration_s?: number | null; // Default: null (infinite)
196
+ }
197
+
198
+ interface TeleoperatorConfig {
199
+ type: "keyboard"; // Only keyboard for now, expandable later
200
+ }
201
+
202
+ async function teleoperate(config: TeleoperateConfig): Promise<void>;
203
+
204
+ // Keyboard control mappings
205
+ interface KeyboardControls {
206
+ shoulder_pan: { decrease: string; increase: string }; // left/right arrows
207
+ shoulder_lift: { decrease: string; increase: string }; // down/up arrows
208
+ elbow_flex: { decrease: string; increase: string }; // s/w
209
+ wrist_flex: { decrease: string; increase: string }; // a/d
210
+ wrist_roll: { decrease: string; increase: string }; // q/e
211
+ gripper: { toggle: string }; // space
212
+ emergency_stop: string; // esc
213
+ }
214
+ ```
215
+
216
+ #### Platform-Specific Keyboard Handling
217
+
218
+ ```typescript
219
+ // Node.js keyboard input
220
+ class NodeKeyboardController {
221
+ private currentPositions: Record<string, number> = {};
222
+ private robot: NodeSO100Follower;
223
+ private stepSize: number;
224
+
225
+ constructor(robot: NodeSO100Follower, stepSize: number = 25) {
226
+ this.robot = robot;
227
+ this.stepSize = stepSize;
228
+ }
229
+
230
+ async start(): Promise<void> {
231
+ process.stdin.setRawMode(true);
232
+ process.stdin.on("keypress", this.handleKeypress.bind(this));
233
+
234
+ // Initialize current positions from robot
235
+ this.currentPositions = await this.robot.getPositions();
236
+ }
237
+
238
+ private async handleKeypress(chunk: any, key: any): Promise<void> {
239
+ let positionChanged = false;
240
+
241
+ switch (key.name) {
242
+ case "up":
243
+ this.currentPositions.shoulder_lift += this.stepSize;
244
+ positionChanged = true;
245
+ break;
246
+ case "down":
247
+ this.currentPositions.shoulder_lift -= this.stepSize;
248
+ positionChanged = true;
249
+ break;
250
+ case "left":
251
+ this.currentPositions.shoulder_pan -= this.stepSize;
252
+ positionChanged = true;
253
+ break;
254
+ case "right":
255
+ this.currentPositions.shoulder_pan += this.stepSize;
256
+ positionChanged = true;
257
+ break;
258
+ // ... other key mappings
259
+ case "escape":
260
+ await this.emergencyStop();
261
+ return;
262
+ }
263
+
264
+ if (positionChanged) {
265
+ // Apply calibration limits
266
+ this.enforcePositionLimits();
267
+ // Send to robot
268
+ await this.robot.setPositions(this.currentPositions);
269
+ }
270
+ }
271
+ }
272
+ ```
273
+
274
+ ```typescript
275
+ // Web keyboard input
276
+ class WebKeyboardController {
277
+ private currentPositions: Record<string, number> = {};
278
+ private robot: WebSO100Follower;
279
+ private stepSize: number;
280
+ private keysPressed: Set<string> = new Set();
281
+
282
+ constructor(robot: WebSO100Follower, stepSize: number = 25) {
283
+ this.robot = robot;
284
+ this.stepSize = stepSize;
285
+ }
286
+
287
+ async start(): Promise<void> {
288
+ document.addEventListener("keydown", this.handleKeyDown.bind(this));
289
+ document.addEventListener("keyup", this.handleKeyUp.bind(this));
290
+
291
+ // Initialize current positions from robot
292
+ this.currentPositions = await this.robot.getPositions();
293
+
294
+ // Start control loop for smooth movement
295
+ this.startControlLoop();
296
+ }
297
+
298
+ private handleKeyDown(event: KeyboardEvent): void {
299
+ event.preventDefault();
300
+ this.keysPressed.add(event.code);
301
+
302
+ if (event.code === "Escape") {
303
+ this.emergencyStop();
304
+ }
305
+ }
306
+
307
+ private async startControlLoop(): Promise<void> {
308
+ setInterval(async () => {
309
+ let positionChanged = false;
310
+
311
+ // Check all pressed keys and update positions
312
+ if (this.keysPressed.has("ArrowUp")) {
313
+ this.currentPositions.shoulder_lift += this.stepSize;
314
+ positionChanged = true;
315
+ }
316
+ if (this.keysPressed.has("ArrowDown")) {
317
+ this.currentPositions.shoulder_lift -= this.stepSize;
318
+ positionChanged = true;
319
+ }
320
+ // ... other key checks
321
+
322
+ if (positionChanged) {
323
+ this.enforcePositionLimits();
324
+ await this.robot.setPositions(this.currentPositions);
325
+ }
326
+ }, 1000 / 60); // 60 FPS control loop
327
+ }
328
+ }
329
+ ```
330
+
331
+ ### Technical Considerations
332
+
333
+ #### Reusing Existing Robot Infrastructure
334
+
335
+ ```typescript
336
+ // Extend existing robot classes instead of reimplementing
337
+ class TeleopSO100Follower extends SO100Follower {
338
+ private calibrationData: CalibrationData;
339
+
340
+ constructor(config: RobotConfig) {
341
+ super(config);
342
+ // Load calibration data from existing calibration system
343
+ this.calibrationData = loadCalibrationData(config.id);
344
+ }
345
+
346
+ async getPositions(): Promise<Record<string, number>> {
347
+ // Reuse existing position reading logic
348
+ return await this.readCurrentPositions();
349
+ }
350
+
351
+ async setPositions(positions: Record<string, number>): Promise<void> {
352
+ // Reuse existing position writing logic with validation
353
+ await this.writePositions(positions);
354
+ }
355
+
356
+ enforcePositionLimits(
357
+ positions: Record<string, number>
358
+ ): Record<string, number> {
359
+ // Use calibration data to enforce limits
360
+ for (const [motor, position] of Object.entries(positions)) {
361
+ const limits = this.calibrationData[motor];
362
+ positions[motor] = Math.max(
363
+ limits.range_min,
364
+ Math.min(limits.range_max, position)
365
+ );
366
+ }
367
+ return positions;
368
+ }
369
+ }
370
+ ```
371
+
372
+ #### Control Loop and Performance
373
+
374
+ ```typescript
375
+ // Simple control loop focused on keyboard input
376
+ async function keyboardControlLoop(
377
+ keyboardController: KeyboardController,
378
+ robot: TeleopSO100Follower,
379
+ fps: number = 60
380
+ ): Promise<void> {
381
+ while (true) {
382
+ const loopStart = performance.now();
383
+
384
+ // Keyboard controller handles input and robot updates internally
385
+ // Just need to display current status
386
+ const positions = await robot.getPositions();
387
+ displayPositions(positions);
388
+
389
+ // Frame rate control
390
+ const loopTime = performance.now() - loopStart;
391
+ const targetLoopTime = 1000 / fps;
392
+ const sleepTime = targetLoopTime - loopTime;
393
+
394
+ if (sleepTime > 0) {
395
+ await sleep(sleepTime);
396
+ }
397
+
398
+ displayPerformanceMetrics(loopTime, fps);
399
+ }
400
+ }
401
+ ```
402
+
403
+ #### CLI Arguments (Simplified)
404
+
405
+ ```typescript
406
+ // CLI interface matching Python structure
407
+ interface TeleoperateConfig {
408
+ robot: {
409
+ type: string; // "so100_follower"
410
+ port: string; // COM port
411
+ id?: string; // robot identifier
412
+ };
413
+ teleop: {
414
+ type: string; // "keyboard"
415
+ };
416
+ step_size?: number; // position step size per keypress
417
+ fps?: number; // control loop frame rate
418
+ }
419
+
420
+ // CLI parsing
421
+ program
422
+ .option("--robot.type <type>", "Robot type (so100_follower)")
423
+ .option("--robot.port <port>", "Robot serial port")
424
+ .option("--robot.id <id>", "Robot identifier")
425
+ .option("--teleop.type <type>", "Teleoperator type (keyboard)")
426
+ .option("--step_size <size>", "Position step size per keypress", "25")
427
+ .option("--fps <fps>", "Control loop frame rate", "60");
428
+ ```
429
+
430
+ ## Definition of Done
431
+
432
+ - [ ] **Functional**: Successfully controls SO-100 robot arm via keyboard input
433
+ - [ ] **CLI Ready**: `npx lerobot teleoperate` provides keyboard control
434
+ - [ ] **Intuitive Controls**: Arrow keys, WASD provide natural robot movement
435
+ - [ ] **Web Compatible**: Browser-based keyboard teleoperation with modern interface
436
+ - [ ] **Cross-Platform**: Node.js works on Windows, macOS, and Linux; Web works in Chromium browsers
437
+ - [ ] **Safety Features**: Position limits, emergency stop, connection monitoring
438
+ - [ ] **Real-time Feedback**: Live position display and performance metrics
439
+ - [ ] **Integration**: Uses existing robot connection and calibration infrastructure
440
+ - [ ] **Error Handling**: Graceful handling of connection failures and invalid movements
441
+ - [ ] **Type Safe**: Full TypeScript coverage with strict mode for both implementations
442
+ - [ ] **Quick Win**: Demonstrable keyboard robot control within minimal development time
package.json CHANGED
@@ -29,6 +29,7 @@
29
  "preview": "vite preview",
30
  "cli:find-port": "tsx src/cli/index.ts find-port",
31
  "cli:calibrate": "tsx src/cli/index.ts calibrate",
 
32
  "prepublishOnly": "pnpm run build",
33
  "install-global": "pnpm run build && npm link"
34
  },
 
29
  "preview": "vite preview",
30
  "cli:find-port": "tsx src/cli/index.ts find-port",
31
  "cli:calibrate": "tsx src/cli/index.ts calibrate",
32
+ "cli:teleoperate": "tsx src/cli/index.ts teleoperate",
33
  "prepublishOnly": "pnpm run build",
34
  "install-global": "pnpm run build && npm link"
35
  },
src/cli/index.ts CHANGED
@@ -9,6 +9,7 @@
9
 
10
  import { findPort } from "../lerobot/node/find_port.js";
11
  import { main as calibrateMain } from "../lerobot/node/calibrate.js";
 
12
 
13
  /**
14
  * Show usage information
@@ -21,12 +22,16 @@ function showUsage() {
21
  " find-port Find the USB port associated with your MotorsBus"
22
  );
23
  console.log(" calibrate Recalibrate your device (robot or teleoperator)");
 
24
  console.log("");
25
  console.log("Examples:");
26
  console.log(" lerobot find-port");
27
  console.log(
28
  " lerobot calibrate --robot.type=so100_follower --robot.port=COM4 --robot.id=my_follower_arm"
29
  );
 
 
 
30
  console.log("");
31
  }
32
 
@@ -55,6 +60,12 @@ async function main() {
55
  await calibrateMain(calibrateArgs);
56
  break;
57
 
 
 
 
 
 
 
58
  case "help":
59
  case "--help":
60
  case "-h":
 
9
 
10
  import { findPort } from "../lerobot/node/find_port.js";
11
  import { main as calibrateMain } from "../lerobot/node/calibrate.js";
12
+ import { main as teleoperateMain } from "../lerobot/node/teleoperate.js";
13
 
14
  /**
15
  * Show usage information
 
22
  " find-port Find the USB port associated with your MotorsBus"
23
  );
24
  console.log(" calibrate Recalibrate your device (robot or teleoperator)");
25
+ console.log(" teleoperate Control a robot using keyboard input");
26
  console.log("");
27
  console.log("Examples:");
28
  console.log(" lerobot find-port");
29
  console.log(
30
  " lerobot calibrate --robot.type=so100_follower --robot.port=COM4 --robot.id=my_follower_arm"
31
  );
32
+ console.log(
33
+ " lerobot teleoperate --robot.type=so100_follower --robot.port=COM4 --teleop.type=keyboard"
34
+ );
35
  console.log("");
36
  }
37
 
 
60
  await calibrateMain(calibrateArgs);
61
  break;
62
 
63
+ case "teleoperate":
64
+ // Pass remaining arguments to teleoperate command
65
+ const teleoperateArgs = args.slice(1);
66
+ await teleoperateMain(teleoperateArgs);
67
+ break;
68
+
69
  case "help":
70
  case "--help":
71
  case "-h":
src/lerobot/node/keyboard_teleop.ts ADDED
@@ -0,0 +1,284 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Keyboard teleoperation controller for Node.js terminal
3
+ * Handles raw keyboard input and robot position control using the keypress package.
4
+ */
5
+
6
+ import * as readline from "readline";
7
+ import { SO100Follower } from "./robots/so100_follower.js";
8
+
9
+ /**
10
+ * Keyboard controller for robot teleoperation
11
+ * Handles terminal keyboard input and robot position updates
12
+ */
13
+ export class KeyboardController {
14
+ private robot: SO100Follower;
15
+ private stepSize: number;
16
+ private currentPositions: Record<string, number> = {};
17
+ private motorNames = [
18
+ "shoulder_pan",
19
+ "shoulder_lift",
20
+ "elbow_flex",
21
+ "wrist_flex",
22
+ "wrist_roll",
23
+ "gripper",
24
+ ];
25
+ private running = false;
26
+ private gripperState = false; // Toggle state for gripper
27
+
28
+ constructor(robot: SO100Follower, stepSize: number = 25) {
29
+ this.robot = robot;
30
+ this.stepSize = stepSize;
31
+ }
32
+
33
+ /**
34
+ * Start keyboard teleoperation
35
+ * Sets up raw keyboard input and initializes robot positions
36
+ */
37
+ async start(): Promise<void> {
38
+ console.log("Initializing keyboard controller...");
39
+
40
+ // Initialize current positions from robot
41
+ try {
42
+ this.currentPositions = await this.readRobotPositions();
43
+ } catch (error) {
44
+ console.warn(
45
+ "Could not read initial robot positions, using calibrated centers"
46
+ );
47
+ // Initialize with calibrated center positions if available, otherwise use middle positions
48
+ const calibratedLimits = this.robot.getCalibrationLimits();
49
+ this.motorNames.forEach((motor) => {
50
+ const limits = calibratedLimits[motor];
51
+ const centerPosition = limits
52
+ ? Math.floor((limits.min + limits.max) / 2)
53
+ : 2047;
54
+ this.currentPositions[motor] = centerPosition;
55
+ });
56
+ }
57
+
58
+ // Set up raw keyboard input
59
+ this.setupKeyboardInput();
60
+ this.running = true;
61
+
62
+ console.log("Keyboard controller ready. Use controls to move robot.");
63
+ }
64
+
65
+ /**
66
+ * Stop keyboard teleoperation
67
+ * Cleans up keyboard input handling
68
+ */
69
+ async stop(): Promise<void> {
70
+ this.running = false;
71
+
72
+ // Reset terminal to normal mode
73
+ if (process.stdin.setRawMode) {
74
+ process.stdin.setRawMode(false);
75
+ }
76
+ process.stdin.removeAllListeners("keypress");
77
+
78
+ console.log("Keyboard controller stopped.");
79
+ }
80
+
81
+ /**
82
+ * Get current robot positions
83
+ */
84
+ async getCurrentPositions(): Promise<Record<string, number>> {
85
+ return { ...this.currentPositions };
86
+ }
87
+
88
+ /**
89
+ * Set up keyboard input handling
90
+ * Uses readline for cross-platform keyboard input
91
+ */
92
+ private setupKeyboardInput(): void {
93
+ // Set up raw mode for immediate key response
94
+ if (process.stdin.setRawMode) {
95
+ process.stdin.setRawMode(true);
96
+ }
97
+ process.stdin.resume();
98
+ process.stdin.setEncoding("utf8");
99
+
100
+ // Handle keyboard input
101
+ process.stdin.on("data", (key: string) => {
102
+ if (!this.running) return;
103
+
104
+ this.handleKeyPress(key);
105
+ });
106
+ }
107
+
108
+ /**
109
+ * Handle individual key presses
110
+ * Maps keys to robot motor movements
111
+ */
112
+ private async handleKeyPress(key: string): Promise<void> {
113
+ let positionChanged = false;
114
+ const newPositions = { ...this.currentPositions };
115
+
116
+ // Handle arrow keys first (they start with ESC but are multi-byte sequences)
117
+ if (key.startsWith("\u001b[")) {
118
+ const arrowKey = key.slice(2);
119
+ switch (arrowKey) {
120
+ case "A": // Up arrow
121
+ newPositions.shoulder_lift += this.stepSize;
122
+ positionChanged = true;
123
+ break;
124
+ case "B": // Down arrow
125
+ newPositions.shoulder_lift -= this.stepSize;
126
+ positionChanged = true;
127
+ break;
128
+ case "C": // Right arrow
129
+ newPositions.shoulder_pan += this.stepSize;
130
+ positionChanged = true;
131
+ break;
132
+ case "D": // Left arrow
133
+ newPositions.shoulder_pan -= this.stepSize;
134
+ positionChanged = true;
135
+ break;
136
+ }
137
+ } else {
138
+ // Handle single character keys
139
+ const keyCode = key.charCodeAt(0);
140
+
141
+ switch (keyCode) {
142
+ // Standalone ESC key (emergency stop)
143
+ case 27:
144
+ if (key.length === 1) {
145
+ console.log("\n🛑 EMERGENCY STOP!");
146
+ await this.emergencyStop();
147
+ return;
148
+ }
149
+ break;
150
+
151
+ // Regular character keys
152
+ case 119: // 'w'
153
+ newPositions.elbow_flex += this.stepSize;
154
+ positionChanged = true;
155
+ break;
156
+ case 115: // 's'
157
+ newPositions.elbow_flex -= this.stepSize;
158
+ positionChanged = true;
159
+ break;
160
+ case 97: // 'a'
161
+ newPositions.wrist_flex -= this.stepSize;
162
+ positionChanged = true;
163
+ break;
164
+ case 100: // 'd'
165
+ newPositions.wrist_flex += this.stepSize;
166
+ positionChanged = true;
167
+ break;
168
+ case 113: // 'q'
169
+ newPositions.wrist_roll -= this.stepSize;
170
+ positionChanged = true;
171
+ break;
172
+ case 101: // 'e'
173
+ newPositions.wrist_roll += this.stepSize;
174
+ positionChanged = true;
175
+ break;
176
+ case 32: // Space
177
+ // Toggle gripper
178
+ this.gripperState = !this.gripperState;
179
+ newPositions.gripper = this.gripperState ? 2300 : 1800;
180
+ positionChanged = true;
181
+ break;
182
+
183
+ // Ctrl+C
184
+ case 3:
185
+ console.log("\nExiting...");
186
+ process.exit(0);
187
+ }
188
+ }
189
+
190
+ if (positionChanged) {
191
+ // Apply position limits using calibration
192
+ this.enforcePositionLimits(newPositions);
193
+
194
+ // Update robot positions - only send changed motors for better performance
195
+ try {
196
+ await this.writeRobotPositions(newPositions);
197
+ this.currentPositions = newPositions;
198
+ } catch (error) {
199
+ console.warn(
200
+ `Failed to update robot positions: ${
201
+ error instanceof Error ? error.message : error
202
+ }`
203
+ );
204
+ }
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Read current positions from robot
210
+ * Uses SO100Follower position reading methods
211
+ */
212
+ private async readRobotPositions(): Promise<Record<string, number>> {
213
+ try {
214
+ return await this.robot.getMotorPositions();
215
+ } catch (error) {
216
+ console.warn(
217
+ `Failed to read robot positions: ${
218
+ error instanceof Error ? error.message : error
219
+ }`
220
+ );
221
+ // Return default positions as fallback
222
+ const positions: Record<string, number> = {};
223
+ this.motorNames.forEach((motor, index) => {
224
+ positions[motor] = 2047; // STS3215 middle position
225
+ });
226
+ return positions;
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Write positions to robot - optimized to only send changed motors
232
+ * This was the key to the smooth performance in the working version
233
+ */
234
+ private async writeRobotPositions(
235
+ newPositions: Record<string, number>
236
+ ): Promise<void> {
237
+ // Only send commands for motors that actually changed
238
+ const changedPositions: Record<string, number> = {};
239
+ let hasChanges = false;
240
+
241
+ for (const [motor, newPosition] of Object.entries(newPositions)) {
242
+ if (Math.abs(this.currentPositions[motor] - newPosition) > 0.5) {
243
+ changedPositions[motor] = newPosition;
244
+ hasChanges = true;
245
+ }
246
+ }
247
+
248
+ if (hasChanges) {
249
+ await this.robot.setMotorPositions(changedPositions);
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Enforce position limits based on calibration data
255
+ * Uses actual calibrated limits instead of hardcoded defaults
256
+ */
257
+ private enforcePositionLimits(positions: Record<string, number>): void {
258
+ // Get calibrated limits from robot
259
+ const calibratedLimits = this.robot.getCalibrationLimits();
260
+
261
+ for (const [motor, position] of Object.entries(positions)) {
262
+ const limits = calibratedLimits[motor];
263
+ if (limits) {
264
+ positions[motor] = Math.max(limits.min, Math.min(limits.max, position));
265
+ }
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Emergency stop - halt all robot movement
271
+ */
272
+ private async emergencyStop(): Promise<void> {
273
+ try {
274
+ // Stop all robot movement
275
+ // TODO: Implement emergency stop in SO100Follower
276
+ console.log("Emergency stop executed.");
277
+ await this.stop();
278
+ process.exit(0);
279
+ } catch (error) {
280
+ console.error("Emergency stop failed:", error);
281
+ process.exit(1);
282
+ }
283
+ }
284
+ }
src/lerobot/node/robots/robot.ts CHANGED
@@ -6,6 +6,7 @@
6
 
7
  import { SerialPort } from "serialport";
8
  import { mkdir, writeFile } from "fs/promises";
 
9
  import { join } from "path";
10
  import type { RobotConfig } from "./config.js";
11
  import { getCalibrationDir, ROBOTS } from "../constants.js";
@@ -16,6 +17,8 @@ export abstract class Robot {
16
  protected calibrationDir: string;
17
  protected calibrationPath: string;
18
  protected name: string;
 
 
19
 
20
  constructor(config: RobotConfig) {
21
  this.config = config;
@@ -29,6 +32,9 @@ export abstract class Robot {
29
  // Use robot ID or type as filename
30
  const robotId = config.id || this.name;
31
  this.calibrationPath = join(this.calibrationDir, `${robotId}.json`);
 
 
 
32
  }
33
 
34
  /**
@@ -98,7 +104,11 @@ export abstract class Robot {
98
  */
99
  protected async saveCalibration(calibrationData: any): Promise<void> {
100
  // Ensure calibration directory exists
101
- await mkdir(this.calibrationDir, { recursive: true });
 
 
 
 
102
 
103
  // Save calibration data as JSON
104
  await writeFile(
@@ -109,6 +119,34 @@ export abstract class Robot {
109
  console.log(`Configuration saved to: ${this.calibrationPath}`);
110
  }
111
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  /**
113
  * Send command to robot via serial port
114
  */
 
6
 
7
  import { SerialPort } from "serialport";
8
  import { mkdir, writeFile } from "fs/promises";
9
+ import { existsSync, readFileSync, mkdirSync } from "fs";
10
  import { join } from "path";
11
  import type { RobotConfig } from "./config.js";
12
  import { getCalibrationDir, ROBOTS } from "../constants.js";
 
17
  protected calibrationDir: string;
18
  protected calibrationPath: string;
19
  protected name: string;
20
+ protected calibration: any = {}; // Loaded calibration data
21
+ protected isCalibrated: boolean = false;
22
 
23
  constructor(config: RobotConfig) {
24
  this.config = config;
 
32
  // Use robot ID or type as filename
33
  const robotId = config.id || this.name;
34
  this.calibrationPath = join(this.calibrationDir, `${robotId}.json`);
35
+
36
+ // Auto-load calibration if it exists (like Python version)
37
+ this.loadCalibration();
38
  }
39
 
40
  /**
 
104
  */
105
  protected async saveCalibration(calibrationData: any): Promise<void> {
106
  // Ensure calibration directory exists
107
+ try {
108
+ mkdirSync(this.calibrationDir, { recursive: true });
109
+ } catch (error) {
110
+ // Directory might already exist, that's fine
111
+ }
112
 
113
  // Save calibration data as JSON
114
  await writeFile(
 
119
  console.log(`Configuration saved to: ${this.calibrationPath}`);
120
  }
121
 
122
+ /**
123
+ * Load calibration data from JSON file
124
+ * Mirrors Python's _load_calibration()
125
+ */
126
+ protected loadCalibration(): void {
127
+ try {
128
+ if (existsSync(this.calibrationPath)) {
129
+ const calibrationData = readFileSync(this.calibrationPath, "utf8");
130
+ this.calibration = JSON.parse(calibrationData);
131
+ this.isCalibrated = true;
132
+ console.log(`✅ Loaded calibration from: ${this.calibrationPath}`);
133
+ } else {
134
+ console.log(
135
+ `⚠️ No calibration file found at: ${this.calibrationPath}`
136
+ );
137
+ this.isCalibrated = false;
138
+ }
139
+ } catch (error) {
140
+ console.warn(
141
+ `Failed to load calibration: ${
142
+ error instanceof Error ? error.message : error
143
+ }`
144
+ );
145
+ this.calibration = {};
146
+ this.isCalibrated = false;
147
+ }
148
+ }
149
+
150
  /**
151
  * Send command to robot via serial port
152
  */
src/lerobot/node/robots/so100_follower.ts CHANGED
@@ -81,6 +81,148 @@ export class SO100Follower extends Robot {
81
  console.log("Robot communication test completed.");
82
  }
83
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  /**
85
  * Read current motor positions
86
  * Implements basic STS3215 servo protocol to read actual positions
@@ -153,7 +295,14 @@ export class SO100Follower extends Robot {
153
  // Extract 16-bit position from Data_L and Data_H
154
  const position = response[5] | (response[6] << 8);
155
  motorPositions.push(position);
156
- console.log(` ${motorName}: ${position} (0-4095 range)`);
 
 
 
 
 
 
 
157
  } else {
158
  console.warn(
159
  ` ${motorName}: Error response (error code: ${error})`
 
81
  console.log("Robot communication test completed.");
82
  }
83
 
84
+ /**
85
+ * Read current motor positions as a record with motor names
86
+ * For teleoperation use
87
+ */
88
+ async getMotorPositions(): Promise<Record<string, number>> {
89
+ const positions = await this.readMotorPositions();
90
+ const motorNames = [
91
+ "shoulder_pan",
92
+ "shoulder_lift",
93
+ "elbow_flex",
94
+ "wrist_flex",
95
+ "wrist_roll",
96
+ "gripper",
97
+ ];
98
+
99
+ const result: Record<string, number> = {};
100
+ for (let i = 0; i < motorNames.length; i++) {
101
+ result[motorNames[i]] = positions[i];
102
+ }
103
+ return result;
104
+ }
105
+
106
+ /**
107
+ * Get calibration data for teleoperation
108
+ * Returns position limits and offsets from calibration file
109
+ */
110
+ getCalibrationLimits(): Record<string, { min: number; max: number }> {
111
+ if (!this.isCalibrated || !this.calibration) {
112
+ console.warn("No calibration data available, using default limits");
113
+ // Default STS3215 limits as fallback
114
+ return {
115
+ shoulder_pan: { min: 985, max: 3085 },
116
+ shoulder_lift: { min: 1200, max: 2800 },
117
+ elbow_flex: { min: 1000, max: 3000 },
118
+ wrist_flex: { min: 1100, max: 2900 },
119
+ wrist_roll: { min: 0, max: 4095 }, // Full rotation motor
120
+ gripper: { min: 1800, max: 2300 },
121
+ };
122
+ }
123
+
124
+ // Extract limits from calibration data (matches Python format)
125
+ const limits: Record<string, { min: number; max: number }> = {};
126
+ for (const [motorName, calibData] of Object.entries(this.calibration)) {
127
+ if (
128
+ calibData &&
129
+ typeof calibData === "object" &&
130
+ "range_min" in calibData &&
131
+ "range_max" in calibData
132
+ ) {
133
+ limits[motorName] = {
134
+ min: Number(calibData.range_min),
135
+ max: Number(calibData.range_max),
136
+ };
137
+ }
138
+ }
139
+
140
+ return limits;
141
+ }
142
+
143
+ /**
144
+ * Set motor positions from a record with motor names
145
+ * For teleoperation use
146
+ */
147
+ async setMotorPositions(positions: Record<string, number>): Promise<void> {
148
+ const motorNames = [
149
+ "shoulder_pan",
150
+ "shoulder_lift",
151
+ "elbow_flex",
152
+ "wrist_flex",
153
+ "wrist_roll",
154
+ "gripper",
155
+ ];
156
+ const motorIds = [1, 2, 3, 4, 5, 6]; // SO-100 has servo IDs 1-6
157
+
158
+ for (let i = 0; i < motorNames.length; i++) {
159
+ const motorName = motorNames[i];
160
+ const motorId = motorIds[i];
161
+ const position = positions[motorName];
162
+
163
+ if (position !== undefined) {
164
+ await this.writeMotorPosition(motorId, position);
165
+ }
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Write position to a single motor
171
+ * Implements STS3215 WRITE_DATA command for position control
172
+ */
173
+ private async writeMotorPosition(
174
+ motorId: number,
175
+ position: number
176
+ ): Promise<void> {
177
+ if (!this.port || !this.port.isOpen) {
178
+ throw new Error("Serial port not open");
179
+ }
180
+
181
+ // Clamp position to valid range
182
+ const clampedPosition = Math.max(0, Math.min(4095, Math.round(position)));
183
+
184
+ // Create STS3215 Write Position packet
185
+ // Format: [0xFF, 0xFF, ID, Length, Instruction, Address, Data_L, Data_H, Checksum]
186
+ // Goal_Position address for STS3215 is 42 (0x2A), length 2 bytes
187
+ const packet = Buffer.from([
188
+ 0xff,
189
+ 0xff, // Header
190
+ motorId, // Servo ID
191
+ 0x05, // Length (Instruction + Address + Data_L + Data_H + Checksum)
192
+ 0x03, // Instruction: WRITE_DATA
193
+ 0x2a, // Address: Goal_Position (42)
194
+ clampedPosition & 0xff, // Data_L (low byte)
195
+ (clampedPosition >> 8) & 0xff, // Data_H (high byte)
196
+ 0x00, // Checksum (will calculate)
197
+ ]);
198
+
199
+ // Calculate checksum: ~(ID + Length + Instruction + Address + Data_L + Data_H) & 0xFF
200
+ const checksum =
201
+ ~(
202
+ motorId +
203
+ 0x05 +
204
+ 0x03 +
205
+ 0x2a +
206
+ (clampedPosition & 0xff) +
207
+ ((clampedPosition >> 8) & 0xff)
208
+ ) & 0xff;
209
+ packet[8] = checksum;
210
+
211
+ // Send write position packet
212
+ await new Promise<void>((resolve, reject) => {
213
+ this.port!.write(packet, (error) => {
214
+ if (error) {
215
+ reject(new Error(`Failed to send write packet: ${error.message}`));
216
+ } else {
217
+ resolve();
218
+ }
219
+ });
220
+ });
221
+
222
+ // Small delay to allow servo to process command
223
+ await new Promise((resolve) => setTimeout(resolve, 1));
224
+ }
225
+
226
  /**
227
  * Read current motor positions
228
  * Implements basic STS3215 servo protocol to read actual positions
 
295
  // Extract 16-bit position from Data_L and Data_H
296
  const position = response[5] | (response[6] << 8);
297
  motorPositions.push(position);
298
+
299
+ // Show calibrated range if available
300
+ const calibratedLimits = this.getCalibrationLimits();
301
+ const limits = calibratedLimits[motorName];
302
+ const rangeText = limits
303
+ ? `(${limits.min}-${limits.max} calibrated)`
304
+ : `(0-4095 raw)`;
305
+ console.log(` ${motorName}: ${position} ${rangeText}`);
306
  } else {
307
  console.warn(
308
  ` ${motorName}: Error response (error code: ${error})`
src/lerobot/node/teleoperate.ts ADDED
@@ -0,0 +1,368 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Robot teleoperation using keyboard control
3
+ *
4
+ * Direct port of Python lerobot teleoperate.py (keyboard portion)
5
+ *
6
+ * Example:
7
+ * ```
8
+ * npx lerobot teleoperate --robot.type=so100_follower --robot.port=COM4 --teleop.type=keyboard
9
+ * ```
10
+ */
11
+
12
+ import type { RobotConfig } from "./robots/config.js";
13
+ import { createSO100Follower } from "./robots/so100_follower.js";
14
+ import { KeyboardController } from "./keyboard_teleop.js";
15
+
16
+ /**
17
+ * Teleoperate configuration interface
18
+ * Matches Python lerobot teleoperate argument structure
19
+ */
20
+ export interface TeleoperateConfig {
21
+ robot: RobotConfig;
22
+ teleop: TeleoperatorConfig;
23
+ fps?: number; // Default: 60
24
+ step_size?: number; // Default: 10 (motor position units)
25
+ duration_s?: number | null; // Default: null (infinite)
26
+ }
27
+
28
+ export interface TeleoperatorConfig {
29
+ type: "keyboard"; // Only keyboard for now, expandable later
30
+ }
31
+
32
+ /**
33
+ * Main teleoperate function
34
+ * Mirrors Python lerobot teleoperate.py structure
35
+ */
36
+ export async function teleoperate(config: TeleoperateConfig): Promise<void> {
37
+ // Validate configuration
38
+ if (!config.robot) {
39
+ throw new Error("Robot configuration is required");
40
+ }
41
+
42
+ if (!config.teleop || config.teleop.type !== "keyboard") {
43
+ throw new Error("Only keyboard teleoperation is currently supported");
44
+ }
45
+
46
+ const fps = config.fps || 60;
47
+ const stepSize = config.step_size || 25;
48
+ const duration = config.duration_s;
49
+
50
+ let robot;
51
+ let keyboardController;
52
+
53
+ try {
54
+ // Create robot
55
+ switch (config.robot.type) {
56
+ case "so100_follower":
57
+ robot = createSO100Follower(config.robot);
58
+ break;
59
+ default:
60
+ throw new Error(`Unsupported robot type: ${config.robot.type}`);
61
+ }
62
+
63
+ console.log(
64
+ `Connecting to robot: ${config.robot.type} on ${config.robot.port}`
65
+ );
66
+ if (config.robot.id) {
67
+ console.log(`Robot ID: ${config.robot.id}`);
68
+ }
69
+
70
+ await robot.connect(false); // calibrate=false
71
+ console.log("Robot connected successfully.");
72
+
73
+ // Show calibration status
74
+ const isCalibrated = (robot as any).isCalibrated;
75
+ if (isCalibrated) {
76
+ console.log(
77
+ `✅ Loaded calibration for: ${config.robot.id || config.robot.type}`
78
+ );
79
+ } else {
80
+ console.log(
81
+ `⚠️ No calibration found for: ${
82
+ config.robot.id || config.robot.type
83
+ } (using defaults)`
84
+ );
85
+ console.log(
86
+ " Run 'npx lerobot calibrate' first for optimal performance!"
87
+ );
88
+ }
89
+
90
+ // Create keyboard controller
91
+ keyboardController = new KeyboardController(robot, stepSize);
92
+
93
+ console.log("");
94
+ console.log("Starting keyboard teleoperation...");
95
+ console.log("Controls:");
96
+ console.log(" ↑↓ Arrow Keys: Shoulder Lift");
97
+ console.log(" ←→ Arrow Keys: Shoulder Pan");
98
+ console.log(" W/S: Elbow Flex");
99
+ console.log(" A/D: Wrist Flex");
100
+ console.log(" Q/E: Wrist Roll");
101
+ console.log(" Space: Gripper Toggle");
102
+ console.log(" ESC: Emergency Stop");
103
+ console.log(" Ctrl+C: Exit");
104
+ console.log("");
105
+ console.log("Press any control key to begin...");
106
+ console.log("");
107
+
108
+ // Start teleoperation control loop
109
+ await teleoperationLoop(keyboardController, robot, fps, duration || null);
110
+ } catch (error) {
111
+ // Ensure we disconnect even if there's an error
112
+ if (keyboardController) {
113
+ try {
114
+ await keyboardController.stop();
115
+ } catch (stopError) {
116
+ console.warn("Warning: Failed to stop keyboard controller properly");
117
+ }
118
+ }
119
+ if (robot) {
120
+ try {
121
+ await robot.disconnect();
122
+ } catch (disconnectError) {
123
+ console.warn("Warning: Failed to disconnect robot properly");
124
+ }
125
+ }
126
+ throw error;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Main teleoperation control loop
132
+ * Provides real-time position feedback and performance metrics
133
+ */
134
+ async function teleoperationLoop(
135
+ keyboardController: KeyboardController,
136
+ robot: any,
137
+ fps: number,
138
+ duration: number | null
139
+ ): Promise<void> {
140
+ console.log("Initializing teleoperation...");
141
+
142
+ // Start keyboard controller
143
+ await keyboardController.start();
144
+
145
+ const startTime = performance.now();
146
+ let loopCount = 0;
147
+
148
+ // Set up graceful shutdown
149
+ let running = true;
150
+ process.on("SIGINT", () => {
151
+ console.log("\nShutting down gracefully...");
152
+ running = false;
153
+ });
154
+
155
+ try {
156
+ // Just wait for the keyboard controller to handle everything
157
+ while (running) {
158
+ // Check duration limit
159
+ if (duration && performance.now() - startTime >= duration * 1000) {
160
+ console.log(`\nDuration limit reached (${duration}s). Stopping...`);
161
+ break;
162
+ }
163
+
164
+ // Small delay to prevent busy waiting
165
+ await new Promise((resolve) => setTimeout(resolve, 100));
166
+ }
167
+ } finally {
168
+ console.log("\nStopping teleoperation...");
169
+ await keyboardController.stop();
170
+ await robot.disconnect();
171
+ console.log("Teleoperation stopped.");
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Display current robot status
177
+ * Shows positions, ranges, and performance metrics
178
+ */
179
+ function displayStatus(
180
+ positions: Record<string, number>,
181
+ loopCount: number,
182
+ avgLoopTime: number
183
+ ): void {
184
+ // Clear screen and show current status
185
+ console.clear();
186
+ console.log("=== KEYBOARD TELEOPERATION ===");
187
+ console.log("");
188
+
189
+ console.log("Current Positions:");
190
+ for (const [motor, position] of Object.entries(positions)) {
191
+ console.log(`${motor}: ${Math.round(position)}`);
192
+ }
193
+
194
+ console.log("");
195
+ const fps = loopCount > 0 ? 1000 / avgLoopTime : 0;
196
+ console.log(
197
+ `Loop: ${avgLoopTime.toFixed(2)}ms (${fps.toFixed(
198
+ 0
199
+ )} Hz) | Status: Connected`
200
+ );
201
+ console.log("");
202
+ console.log("Use arrow keys, WASD, Q/E, Space to control. ESC to stop.");
203
+ }
204
+
205
+ /**
206
+ * Parse command line arguments in Python argparse style
207
+ * Handles --robot.type=so100_follower --teleop.type=keyboard format
208
+ */
209
+ export function parseArgs(args: string[]): TeleoperateConfig {
210
+ const config: Partial<TeleoperateConfig> = {};
211
+
212
+ for (const arg of args) {
213
+ if (arg.startsWith("--robot.")) {
214
+ if (!config.robot) {
215
+ config.robot = { type: "so100_follower", port: "" };
216
+ }
217
+
218
+ const [key, value] = arg.substring(8).split("=");
219
+ switch (key) {
220
+ case "type":
221
+ if (value !== "so100_follower") {
222
+ throw new Error(`Unsupported robot type: ${value}`);
223
+ }
224
+ config.robot.type = value as "so100_follower";
225
+ break;
226
+ case "port":
227
+ config.robot.port = value;
228
+ break;
229
+ case "id":
230
+ config.robot.id = value;
231
+ break;
232
+ default:
233
+ throw new Error(`Unknown robot parameter: ${key}`);
234
+ }
235
+ } else if (arg.startsWith("--teleop.")) {
236
+ if (!config.teleop) {
237
+ config.teleop = { type: "keyboard" };
238
+ }
239
+
240
+ const [key, value] = arg.substring(9).split("=");
241
+ switch (key) {
242
+ case "type":
243
+ if (value !== "keyboard") {
244
+ throw new Error(`Unsupported teleoperator type: ${value}`);
245
+ }
246
+ config.teleop.type = value as "keyboard";
247
+ break;
248
+ default:
249
+ throw new Error(`Unknown teleoperator parameter: ${key}`);
250
+ }
251
+ } else if (arg.startsWith("--fps=")) {
252
+ config.fps = parseInt(arg.substring(6));
253
+ if (isNaN(config.fps) || config.fps <= 0) {
254
+ throw new Error("FPS must be a positive number");
255
+ }
256
+ } else if (arg.startsWith("--step_size=")) {
257
+ config.step_size = parseInt(arg.substring(12));
258
+ if (isNaN(config.step_size) || config.step_size <= 0) {
259
+ throw new Error("Step size must be a positive number");
260
+ }
261
+ } else if (arg.startsWith("--duration_s=")) {
262
+ config.duration_s = parseInt(arg.substring(13));
263
+ if (isNaN(config.duration_s) || config.duration_s <= 0) {
264
+ throw new Error("Duration must be a positive number");
265
+ }
266
+ } else if (arg === "--help" || arg === "-h") {
267
+ showUsage();
268
+ process.exit(0);
269
+ } else if (!arg.startsWith("--")) {
270
+ // Skip non-option arguments
271
+ continue;
272
+ } else {
273
+ throw new Error(`Unknown argument: ${arg}`);
274
+ }
275
+ }
276
+
277
+ // Validate required fields
278
+ if (!config.robot?.port) {
279
+ throw new Error("Robot port is required (--robot.port=PORT)");
280
+ }
281
+ if (!config.teleop?.type) {
282
+ throw new Error("Teleoperator type is required (--teleop.type=keyboard)");
283
+ }
284
+
285
+ return config as TeleoperateConfig;
286
+ }
287
+
288
+ /**
289
+ * Show usage information matching Python argparse output
290
+ */
291
+ function showUsage(): void {
292
+ console.log("Usage: lerobot teleoperate [options]");
293
+ console.log("");
294
+ console.log("Control a robot using keyboard input");
295
+ console.log("");
296
+ console.log("Options:");
297
+ console.log(" --robot.type=TYPE Robot type (so100_follower)");
298
+ console.log(
299
+ " --robot.port=PORT Robot serial port (e.g., COM4, /dev/ttyUSB0)"
300
+ );
301
+ console.log(" --robot.id=ID Robot identifier");
302
+ console.log(" --teleop.type=TYPE Teleoperator type (keyboard)");
303
+ console.log(
304
+ " --fps=FPS Control loop frame rate (default: 60)"
305
+ );
306
+ console.log(
307
+ " --step_size=SIZE Position step size per keypress (default: 10)"
308
+ );
309
+ console.log(" --duration_s=SECONDS Teleoperation duration in seconds");
310
+ console.log(" -h, --help Show this help message");
311
+ console.log("");
312
+ console.log("Keyboard Controls:");
313
+ console.log(" ↑↓ Arrow Keys Shoulder Lift");
314
+ console.log(" ←→ Arrow Keys Shoulder Pan");
315
+ console.log(" W/S Elbow Flex");
316
+ console.log(" A/D Wrist Flex");
317
+ console.log(" Q/E Wrist Roll");
318
+ console.log(" Space Gripper Toggle");
319
+ console.log(" ESC Emergency Stop");
320
+ console.log(" Ctrl+C Exit");
321
+ console.log("");
322
+ console.log("Examples:");
323
+ console.log(
324
+ " lerobot teleoperate --robot.type=so100_follower --robot.port=COM4 --teleop.type=keyboard"
325
+ );
326
+ console.log(
327
+ " lerobot teleoperate --robot.type=so100_follower --robot.port=COM4 --teleop.type=keyboard --fps=30 --step_size=50"
328
+ );
329
+ console.log("");
330
+ console.log("Use 'lerobot find-port' to discover available ports.");
331
+ }
332
+
333
+ /**
334
+ * CLI entry point when called directly
335
+ * Mirrors Python's if __name__ == "__main__": pattern
336
+ */
337
+ export async function main(args: string[]): Promise<void> {
338
+ try {
339
+ if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
340
+ showUsage();
341
+ return;
342
+ }
343
+
344
+ const config = parseArgs(args);
345
+ await teleoperate(config);
346
+ } catch (error) {
347
+ if (error instanceof Error) {
348
+ console.error("Error:", error.message);
349
+ } else {
350
+ console.error("Error:", error);
351
+ }
352
+
353
+ console.error("");
354
+ console.error("Please verify:");
355
+ console.error("1. The robot is connected to the specified port");
356
+ console.error("2. No other application is using the port");
357
+ console.error("3. You have permission to access the port");
358
+ console.error("");
359
+ console.error("Use 'lerobot find-port' to discover available ports.");
360
+
361
+ process.exit(1);
362
+ }
363
+ }
364
+
365
+ if (import.meta.url === `file://${process.argv[1]}`) {
366
+ const args = process.argv.slice(2);
367
+ main(args);
368
+ }
src/lerobot/web/calibrate.ts CHANGED
@@ -782,7 +782,7 @@ export async function saveCalibrationResults(
782
  // Try to save using unified storage system
783
  try {
784
  const { saveCalibrationData } = await import(
785
- "../../demo/lib/unified-storage"
786
  );
787
  saveCalibrationData(serialNumber, fullCalibrationData, metadata);
788
  console.log(
 
782
  // Try to save using unified storage system
783
  try {
784
  const { saveCalibrationData } = await import(
785
+ "../../demo/lib/unified-storage.js"
786
  );
787
  saveCalibrationData(serialNumber, fullCalibrationData, metadata);
788
  console.log(