NERDDISCO commited on
Commit
130bae4
·
1 Parent(s): 7daaa53

feat: move teleoperators out of teleoperate into their own classes (keyboard / direct)

Browse files
docs/conventions.md CHANGED
@@ -20,15 +20,19 @@
20
  - **Direct Library Usage**: End users call library functions directly (e.g., `calibrate()`, `teleoperate()`) - avoid unnecessary abstraction layers
21
  - **Comments**: Write about the functionality, not what you did. We only need to know what the code is doing to make it more easy to understand, not a history of the changes
22
  - **No Reference Comments**: Never write comments like "same pattern as calibrate.ts", "matches Node.js", "copied from X", etc. Comments should explain what the code does, not where it came from or what it's similar to
23
- - **No Change Explanation Comments**: NEVER add comments explaining what you changed or why you removed something. Examples of forbidden comments:
24
 
 
 
 
 
25
  - `// React import not needed with modern JSX transform`
26
  - `// Removed unused import`
27
  - `// Note: Connection manager registration removed for build compatibility`
28
  - `// Card components not needed in this file`
29
  - `// SerialPortRequestOptions unused in current implementation`
30
 
31
- Just make the change cleanly without explaining it in comments.
32
 
33
  ## Project Goals
34
 
 
20
  - **Direct Library Usage**: End users call library functions directly (e.g., `calibrate()`, `teleoperate()`) - avoid unnecessary abstraction layers
21
  - **Comments**: Write about the functionality, not what you did. We only need to know what the code is doing to make it more easy to understand, not a history of the changes
22
  - **No Reference Comments**: Never write comments like "same pattern as calibrate.ts", "matches Node.js", "copied from X", etc. Comments should explain what the code does, not where it came from or what it's similar to
23
+ - **ABSOLUTELY FORBIDDEN: No Change Explanation Comments**: NEVER EVER add comments explaining what you changed, what's new, what's updated, or why you removed something. This is a standard library - there is no "new API", no "old way", no "updated approach". Just code that does what it does. Examples of STRICTLY FORBIDDEN comments:
24
 
25
+ - `// Create teleoperation process using new API`
26
+ - `// Updated API to match Node.js`
27
+ - `// New extensible architecture`
28
+ - `// Breaking change from old API`
29
  - `// React import not needed with modern JSX transform`
30
  - `// Removed unused import`
31
  - `// Note: Connection manager registration removed for build compatibility`
32
  - `// Card components not needed in this file`
33
  - `// SerialPortRequestOptions unused in current implementation`
34
 
35
+ **VIOLATION OF THIS RULE IS NOT TOLERATED.** Just make the change cleanly without explaining it in comments. If you catch yourself writing "new", "old", "updated", "changed", "removed", "added" in a comment - DELETE IT.
36
 
37
  ## Project Goals
38
 
docs/planning/005_extensible_teleoperate.md ADDED
@@ -0,0 +1,606 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # User Story 005: Extensible Teleoperation Architecture
2
+
3
+ ## Story
4
+
5
+ **As a** robotics developer building teleoperation systems with various input devices
6
+ **I want** to use different teleoperators (keyboard, leader arms, joysticks, VR controllers) to control my robot arms
7
+ **So that** I can choose the most appropriate control method for my application without being locked into keyboard-only control
8
+
9
+ ## Background
10
+
11
+ 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.
12
+
13
+ This architectural inconsistency creates several problems:
14
+
15
+ - **Limited Extensibility**: Web users cannot use leader arms or other advanced teleoperators
16
+ - **API Mismatch**: Web API `teleoperate(robotConnection, options)` differs from Node.js API `teleoperate(config: TeleoperateConfig)`
17
+ - **Hardcoded Assumptions**: Keyboard logic is baked into the core teleoperation controller
18
+ - **Future Limitations**: Adding new input devices requires core architecture changes
19
+
20
+ The Python lerobot and Node.js lerobot.js both follow a proper separation where:
21
+
22
+ - **Robots** handle motor communication and hardware control
23
+ - **Teleoperators** handle input device reading and command generation
24
+ - **Teleoperation orchestrator** connects teleoperators to robots
25
+
26
+ 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.
27
+
28
+ ## Acceptance Criteria
29
+
30
+ ### Core Functionality
31
+
32
+ - [ ] **Pluggable Teleoperators**: Support multiple teleoperator types (keyboard, leader arms, etc.)
33
+ - [ ] **API Alignment**: Web API matches Node.js: `teleoperate(config: TeleoperateConfig)`
34
+ - [ ] **Keyboard Teleoperator**: Extract existing keyboard logic into dedicated teleoperator class
35
+ - [ ] **Teleoperator Abstraction**: Base interface that all teleoperators implement
36
+ - [ ] **Type Safety**: Each teleoperator type has its own configuration interface
37
+ - [ ] **State Management**: Maintain current TeleoperationState approach with teleoperator-specific extensions
38
+
39
+ ### User Experience
40
+
41
+ - [ ] **Breaking Change**: Clean API break - no backward compatibility with old hardcoded approach
42
+ - [ ] **Consistent Interface**: Same teleoperation process object regardless of teleoperator type
43
+ - [ ] **Future-Ready**: Easy addition of new teleoperator types without core changes
44
+ - [ ] **Error Handling**: Clear error messages for unsupported or misconfigured teleoperators
45
+
46
+ ### Technical Requirements
47
+
48
+ - [ ] **Architecture Separation**: Clear separation between robot control and teleoperator input
49
+ - [ ] **Web Implementation**: Focus on Web platform to match Node.js architecture
50
+ - [ ] **TypeScript**: Fully typed with union types for teleoperator configurations
51
+ - [ ] **No Code Duplication**: Reuse existing motor communication and robot control logic
52
+ - [ ] **Configuration-Driven**: Teleoperator behavior determined by config, not hardcoded logic
53
+
54
+ ## Expected User Flow
55
+
56
+ ### Keyboard Teleoperation (Current Functionality Preserved)
57
+
58
+ ```typescript
59
+ import { teleoperate } from "lerobot/web/teleoperate";
60
+
61
+ // New API - explicitly specify teleoperator
62
+ const teleoperationProcess = await teleoperate({
63
+ robot: {
64
+ type: "so100_follower",
65
+ port: selectedPort,
66
+ // ... existing robot config
67
+ },
68
+ teleop: {
69
+ type: "keyboard",
70
+ stepSize: 25,
71
+ updateRate: 60,
72
+ },
73
+ calibrationData: loadedCalibrationData,
74
+ onStateUpdate: (state) => {
75
+ // Update UI with current state
76
+ console.log("Robot positions:", state.motorConfigs);
77
+ console.log("Active keys:", state.keyStates);
78
+ },
79
+ });
80
+
81
+ // Same process interface as before
82
+ teleoperationProcess.start();
83
+ teleoperationProcess.updateKeyState("ArrowUp", true);
84
+ teleoperationProcess.stop();
85
+ ```
86
+
87
+ ### Leader Arm Teleoperation (Future)
88
+
89
+ ```typescript
90
+ // Future leader arm teleoperator
91
+ const teleoperationProcess = await teleoperate({
92
+ robot: {
93
+ type: "so100_follower",
94
+ port: followerPort,
95
+ },
96
+ teleop: {
97
+ type: "so100_leader",
98
+ port: leaderPort,
99
+ calibrationData: leaderCalibration,
100
+ positionSmoothing: true,
101
+ },
102
+ calibrationData: followerCalibration,
103
+ onStateUpdate: (state) => {
104
+ console.log("Follower positions:", state.motorConfigs);
105
+ console.log("Leader positions:", state.leaderPositions);
106
+ },
107
+ });
108
+
109
+ teleoperationProcess.start(); // Reads from leader, writes to follower
110
+ ```
111
+
112
+ ### Direct Motor Control
113
+
114
+ ```typescript
115
+ import { teleoperate, DirectTeleoperator } from "@lerobot/web";
116
+
117
+ // Direct motor control for programmatic use (sliders, API calls)
118
+ const teleoperationProcess = await teleoperate({
119
+ robot: {
120
+ type: "so100_follower",
121
+ port: robotPort,
122
+ },
123
+ teleop: {
124
+ type: "direct",
125
+ },
126
+ calibrationData: calibrationData,
127
+ onStateUpdate: (state) => {
128
+ console.log("Robot positions:", state.motorConfigs);
129
+ },
130
+ });
131
+
132
+ // Control motors programmatically
133
+ teleoperationProcess.start();
134
+ const directTeleoperator =
135
+ teleoperationProcess.teleoperator as DirectTeleoperator;
136
+ await directTeleoperator.moveMotor("shoulder_pan", 2500);
137
+ await directTeleoperator.setMotorPositions({
138
+ shoulder_lift: 1800,
139
+ gripper: 3000,
140
+ });
141
+ ```
142
+
143
+ ### Joystick Teleoperation (Future)
144
+
145
+ ```typescript
146
+ // Future joystick teleoperator
147
+ const teleoperationProcess = await teleoperate({
148
+ robot: {
149
+ type: "so100_follower",
150
+ port: robotPort,
151
+ },
152
+ teleop: {
153
+ type: "gamepad",
154
+ controllerIndex: 0,
155
+ axisMapping: {
156
+ leftStick: "shoulder_pan",
157
+ rightStick: "shoulder_lift",
158
+ triggers: "gripper",
159
+ },
160
+ },
161
+ calibrationData: calibrationData,
162
+ });
163
+ ```
164
+
165
+ ### Error Handling
166
+
167
+ ```typescript
168
+ // Unsupported teleoperator type
169
+ try {
170
+ await teleoperate({
171
+ robot: { type: "so100_follower", port: "COM4" },
172
+ teleop: { type: "unsupported_device" },
173
+ });
174
+ } catch (error) {
175
+ console.error("Error: Unsupported teleoperator type: unsupported_device");
176
+ console.error("Supported types: keyboard, so100_leader");
177
+ }
178
+ ```
179
+
180
+ ## Implementation Details
181
+
182
+ ### File Structure
183
+
184
+ ```
185
+ packages/web/src/
186
+ ├── teleoperate.ts # Updated main API (breaking change)
187
+ ├── teleoperators/
188
+ │ ├── base-teleoperator.ts # Base teleoperator interface
189
+ │ ├── keyboard-teleoperator.ts # Extracted keyboard logic
190
+ │ ├── index.ts # Barrel exports
191
+ │ └── [future]
192
+ │ ├── leader-arm-teleoperator.ts
193
+ │ ├── gamepad-teleoperator.ts
194
+ │ └── vr-teleoperator.ts
195
+ └── types/
196
+ └── teleoperation.ts # Updated with teleoperator config types
197
+ ```
198
+
199
+ ### Key Dependencies
200
+
201
+ #### No New Dependencies
202
+
203
+ - **Existing**: Reuse all current Web dependencies (Web Serial API, motor communication utils)
204
+ - **Architecture Only**: This is purely an architectural refactor - no new external dependencies
205
+
206
+ ### Core Functions to Implement
207
+
208
+ #### Updated API (Breaking Change)
209
+
210
+ ```typescript
211
+ // teleoperate.ts - New API matching Node.js
212
+ interface TeleoperateConfig {
213
+ robot: RobotConnection;
214
+ teleop: TeleoperatorConfig;
215
+ calibrationData?: { [motorName: string]: any };
216
+ onStateUpdate?: (state: TeleoperationState) => void;
217
+ }
218
+
219
+ // Union type for all teleoperator configurations
220
+ type TeleoperatorConfig =
221
+ | KeyboardTeleoperatorConfig
222
+ | LeaderArmTeleoperatorConfig
223
+ | GamepadTeleoperatorConfig;
224
+
225
+ // Main function - breaking change from old API
226
+ async function teleoperate(
227
+ config: TeleoperateConfig
228
+ ): Promise<TeleoperationProcess>;
229
+ ```
230
+
231
+ #### Teleoperator Configuration Types
232
+
233
+ ```typescript
234
+ // Base interface all teleoperators implement
235
+ interface BaseTeleoperatorConfig {
236
+ type: string;
237
+ }
238
+
239
+ // Keyboard teleoperator configuration
240
+ interface KeyboardTeleoperatorConfig extends BaseTeleoperatorConfig {
241
+ type: "keyboard";
242
+ stepSize?: number; // Default: 25
243
+ updateRate?: number; // Default: 60 (FPS)
244
+ keyTimeout?: number; // Default: 10000ms
245
+ }
246
+
247
+ // Future: Leader arm teleoperator configuration
248
+ interface LeaderArmTeleoperatorConfig extends BaseTeleoperatorConfig {
249
+ type: "so100_leader";
250
+ port: string;
251
+ calibrationData?: any;
252
+ positionSmoothing?: boolean;
253
+ scaleFactor?: number;
254
+ }
255
+
256
+ // Future: Gamepad teleoperator configuration
257
+ interface GamepadTeleoperatorConfig extends BaseTeleoperatorConfig {
258
+ type: "gamepad";
259
+ controllerIndex?: number;
260
+ axisMapping?: { [axis: string]: string };
261
+ deadzone?: number;
262
+ }
263
+ ```
264
+
265
+ #### Base Teleoperator Interface
266
+
267
+ ```typescript
268
+ // base-teleoperator.ts
269
+ interface WebTeleoperator {
270
+ // Lifecycle management
271
+ initialize(robotConnection: RobotConnection): Promise<void>;
272
+ start(): void;
273
+ stop(): void;
274
+ disconnect(): Promise<void>;
275
+
276
+ // State management
277
+ getState(): TeleoperatorSpecificState;
278
+
279
+ // Robot interaction
280
+ onMotorConfigsUpdate(motorConfigs: MotorConfig[]): void;
281
+ }
282
+
283
+ // Base class with common functionality
284
+ abstract class BaseWebTeleoperator implements WebTeleoperator {
285
+ protected port: MotorCommunicationPort;
286
+ protected motorConfigs: MotorConfig[] = [];
287
+ protected isActive: boolean = false;
288
+ protected onStateUpdate?: (state: TeleoperationState) => void;
289
+
290
+ constructor(
291
+ port: MotorCommunicationPort,
292
+ motorConfigs: MotorConfig[],
293
+ onStateUpdate?: (state: TeleoperationState) => void
294
+ ) {
295
+ this.port = port;
296
+ this.motorConfigs = motorConfigs;
297
+ this.onStateUpdate = onStateUpdate;
298
+ }
299
+
300
+ abstract initialize(): Promise<void>;
301
+ abstract start(): void;
302
+ abstract stop(): void;
303
+ abstract getState(): TeleoperatorSpecificState;
304
+
305
+ async disconnect(): Promise<void> {
306
+ this.stop();
307
+ }
308
+
309
+ onMotorConfigsUpdate(motorConfigs: MotorConfig[]): void {
310
+ this.motorConfigs = motorConfigs;
311
+ }
312
+ }
313
+ ```
314
+
315
+ #### Keyboard Teleoperator (Extracted Logic)
316
+
317
+ ```typescript
318
+ // keyboard-teleoperator.ts - Extract from current WebTeleoperationController
319
+ class KeyboardTeleoperator extends BaseWebTeleoperator {
320
+ private keyboardControls: { [key: string]: KeyboardControl } = {};
321
+ private updateInterval: NodeJS.Timeout | null = null;
322
+ private keyStates: {
323
+ [key: string]: { pressed: boolean; timestamp: number };
324
+ } = {};
325
+
326
+ // Configuration from KeyboardTeleoperatorConfig
327
+ private readonly stepSize: number;
328
+ private readonly updateRate: number;
329
+ private readonly keyTimeout: number;
330
+
331
+ constructor(
332
+ config: KeyboardTeleoperatorConfig,
333
+ port: MotorCommunicationPort,
334
+ motorConfigs: MotorConfig[],
335
+ keyboardControls: { [key: string]: KeyboardControl },
336
+ onStateUpdate?: (state: TeleoperationState) => void
337
+ ) {
338
+ super(port, motorConfigs, onStateUpdate);
339
+ this.keyboardControls = keyboardControls;
340
+
341
+ // Extract configuration
342
+ this.stepSize = config.stepSize ?? 25;
343
+ this.updateRate = config.updateRate ?? 60;
344
+ this.keyTimeout = config.keyTimeout ?? 10000;
345
+ }
346
+
347
+ async initialize(): Promise<void> {
348
+ // Move existing initialization logic here
349
+ for (const config of this.motorConfigs) {
350
+ const position = await readMotorPosition(this.port, config.id);
351
+ if (position !== null) {
352
+ config.currentPosition = position;
353
+ }
354
+ }
355
+ }
356
+
357
+ start(): void {
358
+ // Move existing start logic here
359
+ if (this.isActive) return;
360
+
361
+ this.isActive = true;
362
+ this.updateInterval = setInterval(() => {
363
+ this.updateMotorPositions();
364
+ }, 1000 / this.updateRate);
365
+ }
366
+
367
+ stop(): void {
368
+ // Move existing stop logic here
369
+ if (!this.isActive) return;
370
+
371
+ this.isActive = false;
372
+ if (this.updateInterval) {
373
+ clearInterval(this.updateInterval);
374
+ this.updateInterval = null;
375
+ }
376
+ this.keyStates = {};
377
+
378
+ if (this.onStateUpdate) {
379
+ this.onStateUpdate(this.buildTeleoperationState());
380
+ }
381
+ }
382
+
383
+ updateKeyState(key: string, pressed: boolean): void {
384
+ this.keyStates[key] = { pressed, timestamp: Date.now() };
385
+ }
386
+
387
+ getState(): KeyboardTeleoperatorState {
388
+ return {
389
+ keyStates: { ...this.keyStates },
390
+ };
391
+ }
392
+
393
+ private buildTeleoperationState(): TeleoperationState {
394
+ return {
395
+ isActive: this.isActive,
396
+ motorConfigs: [...this.motorConfigs],
397
+ lastUpdate: Date.now(),
398
+ keyStates: { ...this.keyStates },
399
+ };
400
+ }
401
+
402
+ private updateMotorPositions(): void {
403
+ // Move existing updateMotorPositions logic here
404
+ // ... (existing keyboard processing logic)
405
+ }
406
+ }
407
+ ```
408
+
409
+ #### Teleoperator Factory
410
+
411
+ ```typescript
412
+ // teleoperate.ts - Factory pattern
413
+ async function createTeleoperator(
414
+ config: TeleoperateConfig,
415
+ port: MotorCommunicationPort,
416
+ motorConfigs: MotorConfig[],
417
+ robotHardwareConfig: RobotHardwareConfig
418
+ ): Promise<WebTeleoperator> {
419
+ switch (config.teleop.type) {
420
+ case "keyboard":
421
+ return new KeyboardTeleoperator(
422
+ config.teleop,
423
+ port,
424
+ motorConfigs,
425
+ robotHardwareConfig.keyboardControls,
426
+ config.onStateUpdate
427
+ );
428
+
429
+ case "so100_leader":
430
+ // Future implementation
431
+ throw new Error("Leader arm teleoperator not yet implemented");
432
+
433
+ case "gamepad":
434
+ // Future implementation
435
+ throw new Error("Gamepad teleoperator not yet implemented");
436
+
437
+ default:
438
+ throw new Error(
439
+ `Unsupported teleoperator type: ${(config.teleop as any).type}`
440
+ );
441
+ }
442
+ }
443
+ ```
444
+
445
+ #### Updated Main Teleoperate Function
446
+
447
+ ```typescript
448
+ // teleoperate.ts - Updated main function (breaking change)
449
+ export async function teleoperate(
450
+ config: TeleoperateConfig
451
+ ): Promise<TeleoperationProcess> {
452
+ // Validate required fields
453
+ if (!config.robot.robotType) {
454
+ throw new Error(
455
+ "Robot type is required for teleoperation. Please configure the robot first."
456
+ );
457
+ }
458
+
459
+ // Create web serial port wrapper (same as before)
460
+ const port = new WebSerialPortWrapper(config.robot.port);
461
+ await port.initialize();
462
+
463
+ // Get robot-specific configuration (same as before)
464
+ let robotHardwareConfig: RobotHardwareConfig;
465
+ if (config.robot.robotType.startsWith("so100")) {
466
+ robotHardwareConfig = createSO100Config(config.robot.robotType);
467
+ } else {
468
+ throw new Error(`Unsupported robot type: ${config.robot.robotType}`);
469
+ }
470
+
471
+ // Create motor configs (same as before)
472
+ const defaultMotorConfigs =
473
+ createMotorConfigsFromRobotConfig(robotHardwareConfig);
474
+ const motorConfigs = config.calibrationData
475
+ ? applyCalibrationToMotorConfigs(
476
+ defaultMotorConfigs,
477
+ config.calibrationData
478
+ )
479
+ : defaultMotorConfigs;
480
+
481
+ // Create teleoperator using factory pattern (NEW)
482
+ const teleoperator = await createTeleoperator(
483
+ config,
484
+ port,
485
+ motorConfigs,
486
+ robotHardwareConfig
487
+ );
488
+ await teleoperator.initialize();
489
+
490
+ // Return process object (same interface as before)
491
+ return {
492
+ start: () => {
493
+ teleoperator.start();
494
+ // State update loop (same as before)
495
+ if (config.onStateUpdate) {
496
+ const updateLoop = () => {
497
+ if (teleoperator.getState()) {
498
+ config.onStateUpdate!(
499
+ buildTeleoperationStateFromTeleoperator(teleoperator)
500
+ );
501
+ setTimeout(updateLoop, 100);
502
+ }
503
+ };
504
+ updateLoop();
505
+ }
506
+ },
507
+ stop: () => teleoperator.stop(),
508
+ updateKeyState: (key: string, pressed: boolean) => {
509
+ // Delegate to teleoperator if it supports keyboard input
510
+ if (teleoperator instanceof KeyboardTeleoperator) {
511
+ teleoperator.updateKeyState(key, pressed);
512
+ }
513
+ },
514
+ getState: () => buildTeleoperationStateFromTeleoperator(teleoperator),
515
+ moveMotor: async (motorName: string, position: number) => {
516
+ // Direct motor control through teleoperator
517
+ if (teleoperator instanceof DirectTeleoperator) {
518
+ return teleoperator.moveMotor(motorName, position);
519
+ }
520
+ throw new Error(
521
+ `Motor control not supported for ${config.teleop.type} teleoperator`
522
+ );
523
+ },
524
+ setMotorPositions: async (positions: { [motorName: string]: number }) => {
525
+ // Direct motor control through teleoperator
526
+ if (teleoperator instanceof DirectTeleoperator) {
527
+ return teleoperator.setMotorPositions(positions);
528
+ }
529
+ throw new Error(
530
+ `Motor control not supported for ${config.teleop.type} teleoperator`
531
+ );
532
+ },
533
+ disconnect: () => teleoperator.disconnect(),
534
+ };
535
+ }
536
+ ```
537
+
538
+ ### Technical Considerations
539
+
540
+ #### State Management Strategy
541
+
542
+ Maintain current `TeleoperationState` structure but extend with teleoperator-specific state:
543
+
544
+ ```typescript
545
+ interface TeleoperationState {
546
+ isActive: boolean;
547
+ motorConfigs: MotorConfig[];
548
+ lastUpdate: number;
549
+
550
+ // Teleoperator-specific state (optional fields for different types)
551
+ keyStates?: { [key: string]: { pressed: boolean; timestamp: number } }; // keyboard
552
+ leaderPositions?: { [motor: string]: number }; // leader arm
553
+ gamepadState?: { axes: number[]; buttons: boolean[] }; // gamepad
554
+ }
555
+ ```
556
+
557
+ #### Migration Strategy
558
+
559
+ **Breaking Change Approach:**
560
+
561
+ 1. **Remove old API** - No backward compatibility
562
+ 2. **Update examples** - All demo applications must be updated to use new API
563
+ 3. **Clear documentation** - Document the API change and migration path
564
+ 4. **Type safety** - TypeScript will catch all usages of old API
565
+
566
+ #### Future Extensibility
567
+
568
+ The architecture supports easy addition of new teleoperators:
569
+
570
+ ```typescript
571
+ // Future: Add VR controller
572
+ interface VRTeleoperatorConfig extends BaseTeleoperatorConfig {
573
+ type: "vr_controller";
574
+ handedness: "left" | "right";
575
+ trackingSpace: "local" | "world";
576
+ }
577
+
578
+ class VRTeleoperator extends BaseWebTeleoperator {
579
+ // VR-specific implementation
580
+ }
581
+
582
+ // Add to factory in teleoperate.ts
583
+ case "vr_controller":
584
+ return new VRTeleoperator(config.teleop, port, motorConfigs, config.onStateUpdate);
585
+ ```
586
+
587
+ #### Performance Considerations
588
+
589
+ - **Same Performance**: No performance impact - just architectural refactoring
590
+ - **Memory Usage**: Slightly lower memory usage due to cleaner separation
591
+ - **Extensibility**: No overhead for unused teleoperator types
592
+
593
+ ## Definition of Done
594
+
595
+ - [ ] **API Breaking Change**: Web API updated to `teleoperate(config: TeleoperateConfig)` matching Node.js
596
+ - [ ] **Keyboard Teleoperator**: Existing keyboard functionality extracted into `KeyboardTeleoperator` class
597
+ - [ ] **Base Teleoperator**: `BaseWebTeleoperator` abstract class with common functionality
598
+ - [ ] **Teleoperator Factory**: Factory pattern for creating appropriate teleoperator instances
599
+ - [ ] **Type Safety**: Full TypeScript coverage with union types for teleoperator configurations
600
+ - [ ] **State Management**: Current `TeleoperationState` approach preserved with teleoperator extensions
601
+ - [ ] **Process Interface**: `TeleoperationProcess` interface remains the same for existing UI code
602
+ - [ ] **Error Handling**: Clear error messages for unsupported teleoperator types
603
+ - [ ] **No Regression**: Keyboard teleoperation functionality identical to current implementation
604
+ - [ ] **Future Ready**: Architecture supports easy addition of leader arms, joysticks, VR controllers
605
+ - [ ] **Code Quality**: No code duplication, clean separation of concerns
606
+ - [ ] **Documentation**: Updated examples and documentation for new API
examples/robot-control-web/components/TeleoperationPanel.tsx CHANGED
@@ -7,6 +7,7 @@ import {
7
  teleoperate,
8
  type TeleoperationProcess,
9
  type TeleoperationState,
 
10
  } from "@lerobot/web";
11
  import { getUnifiedRobotData } from "../lib/unified-storage";
12
  import type { RobotConnection } from "@lerobot/web";
@@ -31,9 +32,11 @@ export function TeleoperationPanel({
31
  const [error, setError] = useState<string | null>(null);
32
  const [, setIsInitialized] = useState(false);
33
 
34
- const teleoperationProcessRef = useRef<TeleoperationProcess | null>(null);
 
 
35
 
36
- // Initialize teleoperation process
37
  useEffect(() => {
38
  const initializeTeleoperation = async () => {
39
  if (!robot || !robot.robotType) {
@@ -52,18 +55,36 @@ export function TeleoperationPanel({
52
  }
53
  }
54
 
55
- // Create teleoperation process using clean library API
56
- const process = await teleoperate(robot, {
 
 
 
 
57
  calibrationData,
58
  onStateUpdate: (state: TeleoperationState) => {
59
  setTeleoperationState(state);
60
  },
61
- });
 
 
 
 
 
 
 
 
 
 
 
62
 
63
- teleoperationProcessRef.current = process;
64
- setTeleoperationState(process.getState());
 
65
  setIsInitialized(true);
66
  setError(null);
 
 
67
  } catch (error) {
68
  const errorMessage =
69
  error instanceof Error
@@ -78,9 +99,13 @@ export function TeleoperationPanel({
78
 
79
  return () => {
80
  // Cleanup on unmount
81
- if (teleoperationProcessRef.current) {
82
- teleoperationProcessRef.current.disconnect();
83
- teleoperationProcessRef.current = null;
 
 
 
 
84
  }
85
  };
86
  }, [robot]);
@@ -88,24 +113,22 @@ export function TeleoperationPanel({
88
  // Keyboard event handlers
89
  const handleKeyDown = useCallback(
90
  (event: KeyboardEvent) => {
91
- if (!teleoperationState.isActive || !teleoperationProcessRef.current)
92
- return;
93
 
94
  const key = event.key;
95
  event.preventDefault();
96
- teleoperationProcessRef.current.updateKeyState(key, true);
97
  },
98
  [teleoperationState.isActive]
99
  );
100
 
101
  const handleKeyUp = useCallback(
102
  (event: KeyboardEvent) => {
103
- if (!teleoperationState.isActive || !teleoperationProcessRef.current)
104
- return;
105
 
106
  const key = event.key;
107
  event.preventDefault();
108
- teleoperationProcessRef.current.updateKeyState(key, false);
109
  },
110
  [teleoperationState.isActive]
111
  );
@@ -124,14 +147,15 @@ export function TeleoperationPanel({
124
  }, [teleoperationState.isActive, handleKeyDown, handleKeyUp]);
125
 
126
  const handleStart = () => {
127
- if (!teleoperationProcessRef.current) {
128
  setError("Teleoperation not initialized");
129
  return;
130
  }
131
 
132
  try {
133
- teleoperationProcessRef.current.start();
134
- console.log("🎮 Teleoperation started");
 
135
  } catch (error) {
136
  const errorMessage =
137
  error instanceof Error
@@ -142,36 +166,46 @@ export function TeleoperationPanel({
142
  };
143
 
144
  const handleStop = () => {
145
- if (!teleoperationProcessRef.current) return;
146
-
147
- teleoperationProcessRef.current.stop();
148
- console.log("🛑 Teleoperation stopped");
 
 
 
149
  };
150
 
151
  const handleClose = () => {
152
- if (teleoperationProcessRef.current) {
153
- teleoperationProcessRef.current.stop();
 
 
 
154
  }
155
  onClose();
156
  };
157
 
158
  const simulateKeyPress = (key: string) => {
159
- if (!teleoperationProcessRef.current) return;
160
- teleoperationProcessRef.current.updateKeyState(key, true);
161
  };
162
 
163
  const simulateKeyRelease = (key: string) => {
164
- if (!teleoperationProcessRef.current) return;
165
- teleoperationProcessRef.current.updateKeyState(key, false);
166
  };
167
 
 
 
168
  const moveMotorToPosition = async (motorIndex: number, position: number) => {
169
- if (!teleoperationProcessRef.current) return;
170
 
171
  try {
172
  const motorName = teleoperationState.motorConfigs[motorIndex]?.name;
173
  if (motorName) {
174
- await teleoperationProcessRef.current.moveMotor(motorName, position);
 
 
175
  }
176
  } catch (error) {
177
  console.warn(
@@ -189,7 +223,7 @@ export function TeleoperationPanel({
189
  // Virtual keyboard component
190
  const VirtualKeyboard = () => {
191
  const isKeyPressed = (key: string) => {
192
- return keyStates[key]?.pressed || false;
193
  };
194
 
195
  const KeyButton = ({
@@ -208,42 +242,19 @@ export function TeleoperationPanel({
208
  keyCode as keyof typeof SO100_KEYBOARD_CONTROLS
209
  ];
210
  const pressed = isKeyPressed(keyCode);
211
- const intervalRef = useRef<NodeJS.Timeout | null>(null);
212
 
213
- const startContinuousPress = () => {
214
- if (!isActive || !teleoperationProcessRef.current) return;
215
-
216
- // Initial press
217
  simulateKeyPress(keyCode);
218
-
219
- // Set up continuous updates to maintain key state
220
- // Update every 50ms to stay well within the 10 second timeout
221
- intervalRef.current = setInterval(() => {
222
- if (teleoperationProcessRef.current) {
223
- simulateKeyPress(keyCode);
224
- }
225
- }, 50);
226
  };
227
 
228
- const stopContinuousPress = () => {
229
- if (intervalRef.current) {
230
- clearInterval(intervalRef.current);
231
- intervalRef.current = null;
232
- }
233
- if (isActive) {
234
- simulateKeyRelease(keyCode);
235
- }
236
  };
237
 
238
- // Cleanup interval on unmount
239
- useEffect(() => {
240
- return () => {
241
- if (intervalRef.current) {
242
- clearInterval(intervalRef.current);
243
- }
244
- };
245
- }, []);
246
-
247
  return (
248
  <Button
249
  variant={pressed ? "default" : "outline"}
@@ -259,18 +270,9 @@ export function TeleoperationPanel({
259
  ${!isActive ? "opacity-50 cursor-not-allowed" : ""}
260
  `}
261
  disabled={!isActive}
262
- onMouseDown={(e) => {
263
- e.preventDefault();
264
- startContinuousPress();
265
- }}
266
- onMouseUp={(e) => {
267
- e.preventDefault();
268
- stopContinuousPress();
269
- }}
270
- onMouseLeave={(e) => {
271
- e.preventDefault();
272
- stopContinuousPress();
273
- }}
274
  title={control?.description || keyCode}
275
  >
276
  {children}
@@ -413,8 +415,9 @@ export function TeleoperationPanel({
413
  <span className="text-sm text-gray-600">Active Keys</span>
414
  <Badge variant="outline">
415
  {
416
- Object.values(keyStates).filter((state) => state.pressed)
417
- .length
 
418
  }
419
  </Badge>
420
  </div>
 
7
  teleoperate,
8
  type TeleoperationProcess,
9
  type TeleoperationState,
10
+ type TeleoperateConfig,
11
  } from "@lerobot/web";
12
  import { getUnifiedRobotData } from "../lib/unified-storage";
13
  import type { RobotConnection } from "@lerobot/web";
 
32
  const [error, setError] = useState<string | null>(null);
33
  const [, setIsInitialized] = useState(false);
34
 
35
+ // Separate refs for keyboard and direct teleoperators
36
+ const keyboardProcessRef = useRef<TeleoperationProcess | null>(null);
37
+ const directProcessRef = useRef<TeleoperationProcess | null>(null);
38
 
39
+ // Initialize both teleoperation processes
40
  useEffect(() => {
41
  const initializeTeleoperation = async () => {
42
  if (!robot || !robot.robotType) {
 
55
  }
56
  }
57
 
58
+ // Create keyboard teleoperation process
59
+ const keyboardConfig: TeleoperateConfig = {
60
+ robot: robot,
61
+ teleop: {
62
+ type: "keyboard",
63
+ },
64
  calibrationData,
65
  onStateUpdate: (state: TeleoperationState) => {
66
  setTeleoperationState(state);
67
  },
68
+ };
69
+ const keyboardProcess = await teleoperate(keyboardConfig);
70
+
71
+ // Create direct teleoperation process
72
+ const directConfig: TeleoperateConfig = {
73
+ robot: robot,
74
+ teleop: {
75
+ type: "direct",
76
+ },
77
+ calibrationData,
78
+ };
79
+ const directProcess = await teleoperate(directConfig);
80
 
81
+ keyboardProcessRef.current = keyboardProcess;
82
+ directProcessRef.current = directProcess;
83
+ setTeleoperationState(keyboardProcess.getState());
84
  setIsInitialized(true);
85
  setError(null);
86
+
87
+ console.log("✅ Initialized both keyboard and direct teleoperators");
88
  } catch (error) {
89
  const errorMessage =
90
  error instanceof Error
 
99
 
100
  return () => {
101
  // Cleanup on unmount
102
+ if (keyboardProcessRef.current) {
103
+ keyboardProcessRef.current.disconnect();
104
+ keyboardProcessRef.current = null;
105
+ }
106
+ if (directProcessRef.current) {
107
+ directProcessRef.current.disconnect();
108
+ directProcessRef.current = null;
109
  }
110
  };
111
  }, [robot]);
 
113
  // Keyboard event handlers
114
  const handleKeyDown = useCallback(
115
  (event: KeyboardEvent) => {
116
+ if (!teleoperationState.isActive || !keyboardProcessRef.current) return;
 
117
 
118
  const key = event.key;
119
  event.preventDefault();
120
+ keyboardProcessRef.current.updateKeyState(key, true);
121
  },
122
  [teleoperationState.isActive]
123
  );
124
 
125
  const handleKeyUp = useCallback(
126
  (event: KeyboardEvent) => {
127
+ if (!teleoperationState.isActive || !keyboardProcessRef.current) return;
 
128
 
129
  const key = event.key;
130
  event.preventDefault();
131
+ keyboardProcessRef.current.updateKeyState(key, false);
132
  },
133
  [teleoperationState.isActive]
134
  );
 
147
  }, [teleoperationState.isActive, handleKeyDown, handleKeyUp]);
148
 
149
  const handleStart = () => {
150
+ if (!keyboardProcessRef.current || !directProcessRef.current) {
151
  setError("Teleoperation not initialized");
152
  return;
153
  }
154
 
155
  try {
156
+ keyboardProcessRef.current.start();
157
+ directProcessRef.current.start();
158
+ console.log("🎮 Both keyboard and direct teleoperation started");
159
  } catch (error) {
160
  const errorMessage =
161
  error instanceof Error
 
166
  };
167
 
168
  const handleStop = () => {
169
+ if (keyboardProcessRef.current) {
170
+ keyboardProcessRef.current.stop();
171
+ }
172
+ if (directProcessRef.current) {
173
+ directProcessRef.current.stop();
174
+ }
175
+ console.log("🛑 Both keyboard and direct teleoperation stopped");
176
  };
177
 
178
  const handleClose = () => {
179
+ if (keyboardProcessRef.current) {
180
+ keyboardProcessRef.current.stop();
181
+ }
182
+ if (directProcessRef.current) {
183
+ directProcessRef.current.stop();
184
  }
185
  onClose();
186
  };
187
 
188
  const simulateKeyPress = (key: string) => {
189
+ if (!keyboardProcessRef.current) return;
190
+ keyboardProcessRef.current.updateKeyState(key, true);
191
  };
192
 
193
  const simulateKeyRelease = (key: string) => {
194
+ if (!keyboardProcessRef.current) return;
195
+ keyboardProcessRef.current.updateKeyState(key, false);
196
  };
197
 
198
+ // Unified motor control: Both sliders AND keyboard use the same teleoperator
199
+ // This ensures the UI always shows the correct motor positions
200
  const moveMotorToPosition = async (motorIndex: number, position: number) => {
201
+ if (!keyboardProcessRef.current) return;
202
 
203
  try {
204
  const motorName = teleoperationState.motorConfigs[motorIndex]?.name;
205
  if (motorName) {
206
+ const keyboardTeleoperator = keyboardProcessRef.current
207
+ .teleoperator as any;
208
+ await keyboardTeleoperator.moveMotor(motorName, position);
209
  }
210
  } catch (error) {
211
  console.warn(
 
223
  // Virtual keyboard component
224
  const VirtualKeyboard = () => {
225
  const isKeyPressed = (key: string) => {
226
+ return keyStates?.[key]?.pressed || false;
227
  };
228
 
229
  const KeyButton = ({
 
242
  keyCode as keyof typeof SO100_KEYBOARD_CONTROLS
243
  ];
244
  const pressed = isKeyPressed(keyCode);
 
245
 
246
+ const handleMouseDown = (e: React.MouseEvent) => {
247
+ e.preventDefault();
248
+ if (!isActive) return;
 
249
  simulateKeyPress(keyCode);
 
 
 
 
 
 
 
 
250
  };
251
 
252
+ const handleMouseUp = (e: React.MouseEvent) => {
253
+ e.preventDefault();
254
+ if (!isActive) return;
255
+ simulateKeyRelease(keyCode);
 
 
 
 
256
  };
257
 
 
 
 
 
 
 
 
 
 
258
  return (
259
  <Button
260
  variant={pressed ? "default" : "outline"}
 
270
  ${!isActive ? "opacity-50 cursor-not-allowed" : ""}
271
  `}
272
  disabled={!isActive}
273
+ onMouseDown={handleMouseDown}
274
+ onMouseUp={handleMouseUp}
275
+ onMouseLeave={handleMouseUp}
 
 
 
 
 
 
 
 
 
276
  title={control?.description || keyCode}
277
  >
278
  {children}
 
415
  <span className="text-sm text-gray-600">Active Keys</span>
416
  <Badge variant="outline">
417
  {
418
+ Object.values(keyStates || {}).filter(
419
+ (state) => state.pressed
420
+ ).length
421
  }
422
  </Badge>
423
  </div>
package.json CHANGED
@@ -42,7 +42,7 @@
42
  "install-global": "pnpm run build && npm link"
43
  },
44
  "dependencies": {
45
- "@lerobot/web": "^0.1.1",
46
  "@radix-ui/react-dialog": "^1.1.14",
47
  "@radix-ui/react-progress": "^1.1.7",
48
  "log-update": "^6.1.0",
 
42
  "install-global": "pnpm run build && npm link"
43
  },
44
  "dependencies": {
45
+ "@lerobot/web": "workspace:*",
46
  "@radix-ui/react-dialog": "^1.1.14",
47
  "@radix-ui/react-progress": "^1.1.7",
48
  "log-update": "^6.1.0",
packages/web/src/calibrate.ts CHANGED
@@ -144,7 +144,7 @@ export async function calibrate(
144
  const port = new WebSerialPortWrapper(robotConnection.port);
145
  await port.initialize();
146
 
147
- // Get robot-specific configuration (extensible - add new robot types here)
148
  let config: RobotHardwareConfig;
149
  if (robotConnection.robotType.startsWith("so100")) {
150
  config = createSO100Config(robotConnection.robotType);
 
144
  const port = new WebSerialPortWrapper(robotConnection.port);
145
  await port.initialize();
146
 
147
+ // Get robot-specific configuration
148
  let config: RobotHardwareConfig;
149
  if (robotConnection.robotType.startsWith("so100")) {
150
  config = createSO100Config(robotConnection.robotType);
packages/web/src/index.ts CHANGED
@@ -35,6 +35,9 @@ export type {
35
  MotorConfig,
36
  TeleoperationState,
37
  TeleoperationProcess,
 
 
 
38
  } from "./types/teleoperation.js";
39
 
40
  export type {
@@ -48,3 +51,4 @@ export {
48
  createSO100Config,
49
  SO100_KEYBOARD_CONTROLS,
50
  } from "./robots/so100_config.js";
 
 
35
  MotorConfig,
36
  TeleoperationState,
37
  TeleoperationProcess,
38
+ TeleoperateConfig,
39
+ TeleoperatorConfig,
40
+ DirectTeleoperatorConfig,
41
  } from "./types/teleoperation.js";
42
 
43
  export type {
 
51
  createSO100Config,
52
  SO100_KEYBOARD_CONTROLS,
53
  } from "./robots/so100_config.js";
54
+ export { KEYBOARD_TELEOPERATOR_DEFAULTS } from "./teleoperators/index.js";
packages/web/src/teleoperate.ts CHANGED
@@ -3,10 +3,7 @@
3
  */
4
 
5
  import { createSO100Config } from "./robots/so100_config.js";
6
- import type {
7
- RobotHardwareConfig,
8
- KeyboardControl,
9
- } from "./types/robot-config.js";
10
  import type { RobotConnection } from "./types/robot-connection.js";
11
  import { WebSerialPortWrapper } from "./utils/serial-port-wrapper.js";
12
  import {
@@ -18,18 +15,29 @@ import type {
18
  MotorConfig,
19
  TeleoperationState,
20
  TeleoperationProcess,
 
 
 
21
  } from "./types/teleoperation.js";
 
 
 
 
 
22
 
23
  // Re-export types for external use
24
  export type {
25
  MotorConfig,
26
  TeleoperationState,
27
  TeleoperationProcess,
 
 
 
28
  } from "./types/teleoperation.js";
29
 
30
  /**
31
  * Create motor configurations from robot hardware config
32
- * Pure function - converts robot specs to motor configs with defaults
33
  */
34
  function createMotorConfigsFromRobotConfig(
35
  robotConfig: RobotHardwareConfig
@@ -75,283 +83,145 @@ export function applyCalibrationToMotorConfigs(
75
  }
76
 
77
  /**
78
- * Web teleoperation controller
79
- * Now uses shared utilities instead of custom port handling
80
  */
81
- export class WebTeleoperationController {
82
- private port: MotorCommunicationPort;
83
- private motorConfigs: MotorConfig[] = [];
84
- private keyboardControls: { [key: string]: KeyboardControl } = {};
85
- private isActive: boolean = false;
86
- private updateInterval: NodeJS.Timeout | null = null;
87
- private keyStates: {
88
- [key: string]: { pressed: boolean; timestamp: number };
89
- } = {};
90
- private onStateUpdate?: (state: TeleoperationState) => void;
91
-
92
- // Movement parameters
93
- private readonly STEP_SIZE = 8;
94
- private readonly UPDATE_RATE = 60; // 60 FPS
95
- private readonly KEY_TIMEOUT = 10000; // ms - very long timeout (10 seconds) for virtual buttons
96
-
97
- constructor(
98
- port: MotorCommunicationPort,
99
- motorConfigs: MotorConfig[],
100
- keyboardControls: { [key: string]: KeyboardControl },
101
- onStateUpdate?: (state: TeleoperationState) => void
102
- ) {
103
- this.port = port;
104
- this.motorConfigs = motorConfigs;
105
- this.keyboardControls = keyboardControls;
106
- this.onStateUpdate = onStateUpdate;
107
- }
108
-
109
- async initialize(): Promise<void> {
110
- // Read current motor positions
111
- for (const config of this.motorConfigs) {
112
- const position = await readMotorPosition(this.port, config.id);
113
- if (position !== null) {
114
- config.currentPosition = position;
115
- }
116
- }
117
- }
118
-
119
- getMotorConfigs(): MotorConfig[] {
120
- return [...this.motorConfigs];
121
- }
122
-
123
- getState(): TeleoperationState {
124
- return {
125
- isActive: this.isActive,
126
- motorConfigs: [...this.motorConfigs],
127
- lastUpdate: Date.now(),
128
- keyStates: { ...this.keyStates },
129
- };
130
- }
131
-
132
- updateKeyState(key: string, pressed: boolean): void {
133
- this.keyStates[key] = {
134
- pressed,
135
- timestamp: Date.now(),
136
- };
137
- }
138
-
139
- start(): void {
140
- if (this.isActive) return;
141
-
142
- this.isActive = true;
143
- this.updateInterval = setInterval(() => {
144
- this.updateMotorPositions();
145
- }, 1000 / this.UPDATE_RATE);
146
-
147
- console.log("🎮 Web teleoperation started");
148
- }
149
-
150
- stop(): void {
151
- if (!this.isActive) return;
152
-
153
- this.isActive = false;
154
-
155
- if (this.updateInterval) {
156
- clearInterval(this.updateInterval);
157
- this.updateInterval = null;
158
- }
159
-
160
- // Clear all key states
161
- this.keyStates = {};
162
-
163
- console.log("⏹️ Web teleoperation stopped");
164
-
165
- // Notify UI of state change
166
- if (this.onStateUpdate) {
167
- this.onStateUpdate(this.getState());
168
- }
169
- }
170
-
171
- async disconnect(): Promise<void> {
172
- this.stop();
173
- // No need to manually disconnect - port wrapper handles this
174
- }
175
-
176
- private updateMotorPositions(): void {
177
- const now = Date.now();
178
-
179
- // Clear timed-out keys
180
- Object.keys(this.keyStates).forEach((key) => {
181
- if (now - this.keyStates[key].timestamp > this.KEY_TIMEOUT) {
182
- delete this.keyStates[key];
183
- }
184
- });
185
-
186
- // Process active keys
187
- const activeKeys = Object.keys(this.keyStates).filter(
188
- (key) =>
189
- this.keyStates[key].pressed &&
190
- now - this.keyStates[key].timestamp <= this.KEY_TIMEOUT
191
- );
192
-
193
- // Emergency stop check
194
- if (activeKeys.includes("Escape")) {
195
- this.stop();
196
- return;
197
- }
198
-
199
- // Calculate target positions based on active keys
200
- const targetPositions: { [motorName: string]: number } = {};
201
-
202
- for (const key of activeKeys) {
203
- const control = this.keyboardControls[key];
204
- if (!control || control.motor === "emergency_stop") continue;
205
-
206
- const motorConfig = this.motorConfigs.find(
207
- (m) => m.name === control.motor
208
  );
209
- if (!motorConfig) continue;
210
-
211
- // Calculate new position
212
- const currentTarget =
213
- targetPositions[motorConfig.name] ?? motorConfig.currentPosition;
214
- const newPosition = currentTarget + control.direction * this.STEP_SIZE;
215
 
216
- // Apply limits
217
- targetPositions[motorConfig.name] = Math.max(
218
- motorConfig.minPosition,
219
- Math.min(motorConfig.maxPosition, newPosition)
 
 
220
  );
221
- }
222
 
223
- // Send motor commands
224
- Object.entries(targetPositions).forEach(([motorName, targetPosition]) => {
225
- const motorConfig = this.motorConfigs.find((m) => m.name === motorName);
226
- if (motorConfig && targetPosition !== motorConfig.currentPosition) {
227
- writeMotorPosition(
228
- this.port,
229
- motorConfig.id,
230
- Math.round(targetPosition)
231
- )
232
- .then(() => {
233
- motorConfig.currentPosition = targetPosition;
234
- })
235
- .catch((error) => {
236
- console.warn(
237
- `Failed to write motor ${motorConfig.id} position:`,
238
- error
239
- );
240
- });
241
- }
242
- });
243
- }
244
 
245
- // Programmatic control methods
246
- async moveMotor(motorName: string, targetPosition: number): Promise<boolean> {
247
- const motorConfig = this.motorConfigs.find((m) => m.name === motorName);
248
- if (!motorConfig) return false;
249
-
250
- const clampedPosition = Math.max(
251
- motorConfig.minPosition,
252
- Math.min(motorConfig.maxPosition, targetPosition)
253
- );
254
 
255
- try {
256
- await writeMotorPosition(
257
- this.port,
258
- motorConfig.id,
259
- Math.round(clampedPosition)
260
  );
261
- motorConfig.currentPosition = clampedPosition;
262
- return true;
263
- } catch (error) {
264
- console.warn(`Failed to move motor ${motorName}:`, error);
265
- return false;
266
- }
267
  }
 
268
 
269
- async setMotorPositions(positions: {
270
- [motorName: string]: number;
271
- }): Promise<boolean> {
272
- const results = await Promise.all(
273
- Object.entries(positions).map(([motorName, position]) =>
274
- this.moveMotor(motorName, position)
275
- )
276
- );
277
 
278
- return results.every((result) => result);
279
- }
 
 
 
 
280
  }
281
 
282
  /**
283
- * Main teleoperate function - simple API
284
- * Handles robot types internally, creates appropriate motor configurations
285
  */
286
  export async function teleoperate(
287
- robotConnection: RobotConnection,
288
- options?: {
289
- calibrationData?: { [motorName: string]: any };
290
- onStateUpdate?: (state: TeleoperationState) => void;
291
- }
292
  ): Promise<TeleoperationProcess> {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
293
  // Validate required fields
294
- if (!robotConnection.robotType) {
295
  throw new Error(
296
  "Robot type is required for teleoperation. Please configure the robot first."
297
  );
298
  }
299
 
300
  // Create web serial port wrapper
301
- const port = new WebSerialPortWrapper(robotConnection.port);
302
  await port.initialize();
303
 
304
  // Get robot-specific configuration
305
- let config: RobotHardwareConfig;
306
- if (robotConnection.robotType.startsWith("so100")) {
307
- config = createSO100Config(robotConnection.robotType);
308
  } else {
309
- throw new Error(`Unsupported robot type: ${robotConnection.robotType}`);
310
  }
311
 
312
- // Create motor configs from robot hardware specs (single call, no duplication)
313
- const defaultMotorConfigs = createMotorConfigsFromRobotConfig(config);
 
314
 
315
  // Apply calibration data if provided
316
- const motorConfigs = options?.calibrationData
317
  ? applyCalibrationToMotorConfigs(
318
  defaultMotorConfigs,
319
- options.calibrationData
320
  )
321
  : defaultMotorConfigs;
322
 
323
- // Create and initialize controller using shared utilities
324
- const controller = new WebTeleoperationController(
 
325
  port,
326
  motorConfigs,
327
- config.keyboardControls,
328
- options?.onStateUpdate
329
  );
330
- await controller.initialize();
331
 
332
- // Wrap controller in process object
333
- return {
334
- start: () => {
335
- controller.start();
336
- // Optional state update callback
337
- if (options?.onStateUpdate) {
338
- const updateLoop = () => {
339
- if (controller.getState().isActive) {
340
- options.onStateUpdate!(controller.getState());
341
- setTimeout(updateLoop, 100); // 10fps state updates
342
- }
343
- };
344
- updateLoop();
345
- }
346
- },
347
- stop: () => controller.stop(),
348
- updateKeyState: (key: string, pressed: boolean) =>
349
- controller.updateKeyState(key, pressed),
350
- getState: () => controller.getState(),
351
- moveMotor: (motorName: string, position: number) =>
352
- controller.moveMotor(motorName, position),
353
- setMotorPositions: (positions: { [motorName: string]: number }) =>
354
- controller.setMotorPositions(positions),
355
- disconnect: () => controller.disconnect(),
356
- };
357
  }
 
3
  */
4
 
5
  import { createSO100Config } from "./robots/so100_config.js";
6
+ import type { RobotHardwareConfig } from "./types/robot-config.js";
 
 
 
7
  import type { RobotConnection } from "./types/robot-connection.js";
8
  import { WebSerialPortWrapper } from "./utils/serial-port-wrapper.js";
9
  import {
 
15
  MotorConfig,
16
  TeleoperationState,
17
  TeleoperationProcess,
18
+ TeleoperateConfig,
19
+ TeleoperatorConfig,
20
+ DirectTeleoperatorConfig,
21
  } from "./types/teleoperation.js";
22
+ import {
23
+ KeyboardTeleoperator,
24
+ DirectTeleoperator,
25
+ type WebTeleoperator,
26
+ } from "./teleoperators/index.js";
27
 
28
  // Re-export types for external use
29
  export type {
30
  MotorConfig,
31
  TeleoperationState,
32
  TeleoperationProcess,
33
+ TeleoperateConfig,
34
+ TeleoperatorConfig,
35
+ DirectTeleoperatorConfig,
36
  } from "./types/teleoperation.js";
37
 
38
  /**
39
  * Create motor configurations from robot hardware config
40
+ * Pure function - converts robot specs to motor configs
41
  */
42
  function createMotorConfigsFromRobotConfig(
43
  robotConfig: RobotHardwareConfig
 
83
  }
84
 
85
  /**
86
+ * Create appropriate teleoperator based on configuration
 
87
  */
88
+ async function createTeleoperator(
89
+ config: TeleoperateConfig,
90
+ port: MotorCommunicationPort,
91
+ motorConfigs: MotorConfig[],
92
+ robotHardwareConfig: RobotHardwareConfig
93
+ ): Promise<WebTeleoperator> {
94
+ switch (config.teleop.type) {
95
+ case "keyboard":
96
+ return new KeyboardTeleoperator(
97
+ config.teleop,
98
+ port,
99
+ motorConfigs,
100
+ robotHardwareConfig.keyboardControls,
101
+ config.onStateUpdate
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  );
 
 
 
 
 
 
103
 
104
+ case "direct":
105
+ return new DirectTeleoperator(
106
+ config.teleop,
107
+ port,
108
+ motorConfigs,
109
+ config.onStateUpdate
110
  );
 
111
 
112
+ case "so100_leader":
113
+ throw new Error("Leader arm teleoperator not yet implemented");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
 
115
+ case "gamepad":
116
+ throw new Error("Gamepad teleoperator not yet implemented");
 
 
 
 
 
 
 
117
 
118
+ default:
119
+ throw new Error(
120
+ `Unsupported teleoperator type: ${(config.teleop as any).type}`
 
 
121
  );
 
 
 
 
 
 
122
  }
123
+ }
124
 
125
+ /**
126
+ * Build TeleoperationState from teleoperator and motor configs
127
+ */
128
+ function buildTeleoperationStateFromTeleoperator(
129
+ teleoperator: WebTeleoperator
130
+ ): TeleoperationState {
131
+ const teleoperatorState = teleoperator.getState();
132
+ const isActive = (teleoperator as any).isActive;
133
 
134
+ return {
135
+ isActive: isActive || false,
136
+ motorConfigs: [...teleoperator.motorConfigs], // Get fresh motor configs from teleoperator
137
+ lastUpdate: Date.now(),
138
+ ...teleoperatorState,
139
+ };
140
  }
141
 
142
  /**
143
+ * Main teleoperate function
 
144
  */
145
  export async function teleoperate(
146
+ config: TeleoperateConfig
 
 
 
 
147
  ): Promise<TeleoperationProcess> {
148
+ const teleoperator = await createTeleoperatorProcess(config);
149
+ const motorConfigs = teleoperator.motorConfigs;
150
+
151
+ return {
152
+ start: () => {
153
+ teleoperator.start();
154
+ // CRITICAL: State update loop for UI synchronization
155
+ // This ensures sliders and UI reflect actual motor positions when moved via keyboard
156
+ if (config.onStateUpdate) {
157
+ const updateLoop = () => {
158
+ const state = buildTeleoperationStateFromTeleoperator(teleoperator);
159
+ if (state.isActive) {
160
+ config.onStateUpdate!(state);
161
+ setTimeout(updateLoop, 100); // 10fps state updates - keeps sliders in sync
162
+ }
163
+ };
164
+ updateLoop();
165
+ }
166
+ },
167
+ stop: () => teleoperator.stop(),
168
+ updateKeyState: (key: string, pressed: boolean) => {
169
+ // Delegate to teleoperator if it supports keyboard input
170
+ if (teleoperator instanceof KeyboardTeleoperator) {
171
+ teleoperator.updateKeyState(key, pressed);
172
+ }
173
+ },
174
+ getState: () => buildTeleoperationStateFromTeleoperator(teleoperator),
175
+ teleoperator,
176
+ disconnect: () => teleoperator.disconnect(),
177
+ };
178
+ }
179
+
180
+ /**
181
+ * Create teleoperator instance (shared logic)
182
+ */
183
+ async function createTeleoperatorProcess(
184
+ config: TeleoperateConfig
185
+ ): Promise<WebTeleoperator> {
186
  // Validate required fields
187
+ if (!config.robot.robotType) {
188
  throw new Error(
189
  "Robot type is required for teleoperation. Please configure the robot first."
190
  );
191
  }
192
 
193
  // Create web serial port wrapper
194
+ const port = new WebSerialPortWrapper(config.robot.port);
195
  await port.initialize();
196
 
197
  // Get robot-specific configuration
198
+ let robotHardwareConfig: RobotHardwareConfig;
199
+ if (config.robot.robotType.startsWith("so100")) {
200
+ robotHardwareConfig = createSO100Config(config.robot.robotType);
201
  } else {
202
+ throw new Error(`Unsupported robot type: ${config.robot.robotType}`);
203
  }
204
 
205
+ // Create motor configs from robot hardware specs
206
+ const defaultMotorConfigs =
207
+ createMotorConfigsFromRobotConfig(robotHardwareConfig);
208
 
209
  // Apply calibration data if provided
210
+ const motorConfigs = config.calibrationData
211
  ? applyCalibrationToMotorConfigs(
212
  defaultMotorConfigs,
213
+ config.calibrationData
214
  )
215
  : defaultMotorConfigs;
216
 
217
+ // Create teleoperator
218
+ const teleoperator = await createTeleoperator(
219
+ config,
220
  port,
221
  motorConfigs,
222
+ robotHardwareConfig
 
223
  );
224
+ await teleoperator.initialize();
225
 
226
+ return teleoperator;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  }
packages/web/src/teleoperators/base-teleoperator.ts ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Base teleoperator interface and abstract class for Web platform
3
+ * Defines the contract that all teleoperators must implement
4
+ */
5
+
6
+ import type { MotorConfig } from "../types/teleoperation.js";
7
+ import type { MotorCommunicationPort } from "../utils/motor-communication.js";
8
+
9
+ /**
10
+ * Base interface that all Web teleoperators must implement
11
+ */
12
+ export interface WebTeleoperator {
13
+ initialize(): Promise<void>;
14
+ start(): void;
15
+ stop(): void;
16
+ disconnect(): Promise<void>;
17
+ getState(): TeleoperatorSpecificState;
18
+ onMotorConfigsUpdate(motorConfigs: MotorConfig[]): void;
19
+ motorConfigs: MotorConfig[];
20
+ }
21
+
22
+ /**
23
+ * Teleoperator-specific state (union type for different teleoperator types)
24
+ */
25
+ export type TeleoperatorSpecificState = {
26
+ keyStates?: { [key: string]: { pressed: boolean; timestamp: number } }; // keyboard
27
+ leaderPositions?: { [motor: string]: number }; // leader arm
28
+ gamepadState?: { axes: number[]; buttons: boolean[] }; // gamepad
29
+ };
30
+
31
+ /**
32
+ * Base abstract class with common functionality for all teleoperators
33
+ */
34
+ export abstract class BaseWebTeleoperator implements WebTeleoperator {
35
+ protected port: MotorCommunicationPort;
36
+ public motorConfigs: MotorConfig[] = [];
37
+ protected isActive: boolean = false;
38
+
39
+ constructor(port: MotorCommunicationPort, motorConfigs: MotorConfig[]) {
40
+ this.port = port;
41
+ this.motorConfigs = motorConfigs;
42
+ }
43
+
44
+ abstract initialize(): Promise<void>;
45
+ abstract start(): void;
46
+ abstract stop(): void;
47
+ abstract getState(): TeleoperatorSpecificState;
48
+
49
+ async disconnect(): Promise<void> {
50
+ this.stop();
51
+ }
52
+
53
+ onMotorConfigsUpdate(motorConfigs: MotorConfig[]): void {
54
+ this.motorConfigs = motorConfigs;
55
+ }
56
+
57
+ get isActiveTeleoperator(): boolean {
58
+ return this.isActive;
59
+ }
60
+ }
packages/web/src/teleoperators/direct-teleoperator.ts ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Direct teleoperator for Web platform
3
+ * Handles programmatic motor control (sliders, API calls, etc.)
4
+ */
5
+
6
+ import {
7
+ BaseWebTeleoperator,
8
+ type TeleoperatorSpecificState,
9
+ } from "./base-teleoperator.js";
10
+ import type {
11
+ DirectTeleoperatorConfig,
12
+ MotorConfig,
13
+ TeleoperationState,
14
+ } from "../types/teleoperation.js";
15
+ import type { MotorCommunicationPort } from "../utils/motor-communication.js";
16
+ import {
17
+ readMotorPosition,
18
+ writeMotorPosition,
19
+ } from "../utils/motor-communication.js";
20
+
21
+ export class DirectTeleoperator extends BaseWebTeleoperator {
22
+ private onStateUpdate?: (state: TeleoperationState) => void;
23
+
24
+ constructor(
25
+ config: DirectTeleoperatorConfig,
26
+ port: MotorCommunicationPort,
27
+ motorConfigs: MotorConfig[],
28
+ onStateUpdate?: (state: TeleoperationState) => void
29
+ ) {
30
+ super(port, motorConfigs);
31
+ this.onStateUpdate = onStateUpdate;
32
+ }
33
+
34
+ async initialize(): Promise<void> {
35
+ // Read current motor positions
36
+ for (const config of this.motorConfigs) {
37
+ const position = await readMotorPosition(this.port, config.id);
38
+ if (position !== null) {
39
+ config.currentPosition = position;
40
+ }
41
+ }
42
+ }
43
+
44
+ start(): void {
45
+ this.isActive = true;
46
+ }
47
+
48
+ stop(): void {
49
+ this.isActive = false;
50
+
51
+ // Notify UI of state change
52
+ if (this.onStateUpdate) {
53
+ this.onStateUpdate(this.buildTeleoperationState());
54
+ }
55
+ }
56
+
57
+ getState(): TeleoperatorSpecificState {
58
+ return {};
59
+ }
60
+
61
+ /**
62
+ * Move motor to exact position
63
+ */
64
+ async moveMotor(motorName: string, targetPosition: number): Promise<boolean> {
65
+ const motorConfig = this.motorConfigs.find((m) => m.name === motorName);
66
+ if (!motorConfig) return false;
67
+
68
+ const clampedPosition = Math.max(
69
+ motorConfig.minPosition,
70
+ Math.min(motorConfig.maxPosition, targetPosition)
71
+ );
72
+
73
+ try {
74
+ await writeMotorPosition(
75
+ this.port,
76
+ motorConfig.id,
77
+ Math.round(clampedPosition)
78
+ );
79
+ motorConfig.currentPosition = clampedPosition;
80
+
81
+ // Notify UI of position change
82
+ if (this.onStateUpdate) {
83
+ this.onStateUpdate(this.buildTeleoperationState());
84
+ }
85
+
86
+ return true;
87
+ } catch (error) {
88
+ console.warn(`Failed to move motor ${motorName}:`, error);
89
+ return false;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Set multiple motor positions at once
95
+ */
96
+ async setMotorPositions(positions: {
97
+ [motorName: string]: number;
98
+ }): Promise<boolean> {
99
+ const results = await Promise.all(
100
+ Object.entries(positions).map(([motorName, position]) =>
101
+ this.moveMotor(motorName, position)
102
+ )
103
+ );
104
+
105
+ return results.every((result) => result);
106
+ }
107
+
108
+ private buildTeleoperationState(): TeleoperationState {
109
+ return {
110
+ isActive: this.isActive,
111
+ motorConfigs: [...this.motorConfigs],
112
+ lastUpdate: Date.now(),
113
+ };
114
+ }
115
+ }
packages/web/src/teleoperators/index.ts ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Teleoperators barrel exports
3
+ */
4
+
5
+ export {
6
+ BaseWebTeleoperator,
7
+ type WebTeleoperator,
8
+ type TeleoperatorSpecificState,
9
+ } from "./base-teleoperator.js";
10
+ export {
11
+ KeyboardTeleoperator,
12
+ KEYBOARD_TELEOPERATOR_DEFAULTS,
13
+ } from "./keyboard-teleoperator.js";
14
+ export { DirectTeleoperator } from "./direct-teleoperator.js";
packages/web/src/teleoperators/keyboard-teleoperator.ts ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Keyboard teleoperator for Web platform
3
+ */
4
+
5
+ import {
6
+ BaseWebTeleoperator,
7
+ type TeleoperatorSpecificState,
8
+ } from "./base-teleoperator.js";
9
+ import type { KeyboardControl } from "../types/robot-config.js";
10
+ import type {
11
+ KeyboardTeleoperatorConfig,
12
+ MotorConfig,
13
+ TeleoperationState,
14
+ } from "../types/teleoperation.js";
15
+ import type { MotorCommunicationPort } from "../utils/motor-communication.js";
16
+ import {
17
+ readMotorPosition,
18
+ writeMotorPosition,
19
+ } from "../utils/motor-communication.js";
20
+
21
+ /**
22
+ * Default configuration values for keyboard teleoperator
23
+ */
24
+ export const KEYBOARD_TELEOPERATOR_DEFAULTS = {
25
+ stepSize: 8, // Position units per keypress (smooth responsive control)
26
+ updateRate: 60, // Control loop FPS (60 Hz for smooth updates)
27
+ keyTimeout: 10000, // Key state timeout in ms (10 seconds for virtual buttons)
28
+ } as const;
29
+
30
+ export class KeyboardTeleoperator extends BaseWebTeleoperator {
31
+ private keyboardControls: { [key: string]: KeyboardControl } = {};
32
+ private updateInterval: NodeJS.Timeout | null = null;
33
+ private keyStates: {
34
+ [key: string]: { pressed: boolean; timestamp: number };
35
+ } = {};
36
+ private onStateUpdate?: (state: TeleoperationState) => void;
37
+
38
+ // Configuration values
39
+ private readonly stepSize: number;
40
+ private readonly updateRate: number;
41
+ private readonly keyTimeout: number;
42
+
43
+ constructor(
44
+ config: KeyboardTeleoperatorConfig,
45
+ port: MotorCommunicationPort,
46
+ motorConfigs: MotorConfig[],
47
+ keyboardControls: { [key: string]: KeyboardControl },
48
+ onStateUpdate?: (state: TeleoperationState) => void
49
+ ) {
50
+ super(port, motorConfigs);
51
+ this.keyboardControls = keyboardControls;
52
+ this.onStateUpdate = onStateUpdate;
53
+
54
+ // Set configuration values
55
+ this.stepSize = config.stepSize ?? KEYBOARD_TELEOPERATOR_DEFAULTS.stepSize;
56
+ this.updateRate =
57
+ config.updateRate ?? KEYBOARD_TELEOPERATOR_DEFAULTS.updateRate;
58
+ this.keyTimeout =
59
+ config.keyTimeout ?? KEYBOARD_TELEOPERATOR_DEFAULTS.keyTimeout;
60
+ }
61
+
62
+ async initialize(): Promise<void> {
63
+ // Read current motor positions
64
+ for (const config of this.motorConfigs) {
65
+ const position = await readMotorPosition(this.port, config.id);
66
+ if (position !== null) {
67
+ config.currentPosition = position;
68
+ }
69
+ }
70
+ }
71
+
72
+ start(): void {
73
+ if (this.isActive) return;
74
+
75
+ this.isActive = true;
76
+ this.updateInterval = setInterval(() => {
77
+ this.updateMotorPositions();
78
+ }, 1000 / this.updateRate);
79
+ }
80
+
81
+ stop(): void {
82
+ if (!this.isActive) return;
83
+
84
+ this.isActive = false;
85
+
86
+ if (this.updateInterval) {
87
+ clearInterval(this.updateInterval);
88
+ this.updateInterval = null;
89
+ }
90
+
91
+ // Clear all key states
92
+ this.keyStates = {};
93
+
94
+ // Notify UI of state change
95
+ if (this.onStateUpdate) {
96
+ this.onStateUpdate(this.buildTeleoperationState());
97
+ }
98
+ }
99
+
100
+ getState(): TeleoperatorSpecificState {
101
+ return {
102
+ keyStates: { ...this.keyStates },
103
+ };
104
+ }
105
+
106
+ updateKeyState(key: string, pressed: boolean): void {
107
+ this.keyStates[key] = {
108
+ pressed,
109
+ timestamp: Date.now(),
110
+ };
111
+ }
112
+
113
+ /**
114
+ * Move motor to exact position (for sliders and direct control)
115
+ * This ensures sliders update the same motor configs that the UI displays
116
+ */
117
+ async moveMotor(motorName: string, targetPosition: number): Promise<boolean> {
118
+ const motorConfig = this.motorConfigs.find((m) => m.name === motorName);
119
+ if (!motorConfig) return false;
120
+
121
+ const clampedPosition = Math.max(
122
+ motorConfig.minPosition,
123
+ Math.min(motorConfig.maxPosition, targetPosition)
124
+ );
125
+
126
+ try {
127
+ await writeMotorPosition(
128
+ this.port,
129
+ motorConfig.id,
130
+ Math.round(clampedPosition)
131
+ );
132
+ motorConfig.currentPosition = clampedPosition;
133
+
134
+ // Notify UI of position change for immediate slider update
135
+ if (this.onStateUpdate) {
136
+ this.onStateUpdate(this.buildTeleoperationState());
137
+ }
138
+
139
+ return true;
140
+ } catch (error) {
141
+ console.warn(`Failed to move motor ${motorName}:`, error);
142
+ return false;
143
+ }
144
+ }
145
+
146
+ private buildTeleoperationState(): TeleoperationState {
147
+ return {
148
+ isActive: this.isActive,
149
+ motorConfigs: [...this.motorConfigs],
150
+ lastUpdate: Date.now(),
151
+ keyStates: { ...this.keyStates },
152
+ };
153
+ }
154
+
155
+ /**
156
+ * IMPORTANT: This method implements the WORKING keyboard control logic.
157
+ *
158
+ * ⚠️ DO NOT MODIFY THIS LOGIC! ⚠️
159
+ *
160
+ * This simple approach works perfectly:
161
+ * - If key is pressed → apply movement every update cycle
162
+ * - stepSize: 8 units per cycle at 60Hz = smooth, responsive control
163
+ * - Single taps work naturally (brief key press = few cycles = small movement)
164
+ * - Held keys work naturally (continuous press = continuous movement)
165
+ *
166
+ * Previous "improvements" that BROKE this:
167
+ * ❌ Trying to detect "first press" vs "held" - breaks everything
168
+ * ❌ Adding delays/thresholds - makes it clunky
169
+ * ❌ Event-driven immediate movement - causes multiple applications
170
+ * ❌ Higher stepSize values - too large jumps
171
+ *
172
+ * Keep it simple - this works!
173
+ */
174
+ private updateMotorPositions(): void {
175
+ const now = Date.now();
176
+
177
+ // Clear timed-out keys
178
+ Object.keys(this.keyStates).forEach((key) => {
179
+ if (now - this.keyStates[key].timestamp > this.keyTimeout) {
180
+ delete this.keyStates[key];
181
+ }
182
+ });
183
+
184
+ // Process active keys
185
+ const activeKeys = Object.keys(this.keyStates).filter(
186
+ (key) =>
187
+ this.keyStates[key].pressed &&
188
+ now - this.keyStates[key].timestamp <= this.keyTimeout
189
+ );
190
+
191
+ // Emergency stop check
192
+ if (activeKeys.includes("Escape")) {
193
+ this.stop();
194
+ return;
195
+ }
196
+
197
+ // Calculate target positions based on active keys
198
+ // SIMPLE RULE: If key is pressed → apply movement (works perfectly!)
199
+ const targetPositions: { [motorName: string]: number } = {};
200
+
201
+ for (const key of activeKeys) {
202
+ const control = this.keyboardControls[key];
203
+ if (!control || control.motor === "emergency_stop") continue;
204
+
205
+ const motorConfig = this.motorConfigs.find(
206
+ (m) => m.name === control.motor
207
+ );
208
+ if (!motorConfig) continue;
209
+
210
+ // Calculate new position
211
+ const currentTarget =
212
+ targetPositions[motorConfig.name] ?? motorConfig.currentPosition;
213
+ const newPosition = currentTarget + control.direction * this.stepSize;
214
+
215
+ // Apply limits
216
+ targetPositions[motorConfig.name] = Math.max(
217
+ motorConfig.minPosition,
218
+ Math.min(motorConfig.maxPosition, newPosition)
219
+ );
220
+ }
221
+
222
+ // Send motor commands and update positions
223
+ Object.entries(targetPositions).forEach(([motorName, targetPosition]) => {
224
+ const motorConfig = this.motorConfigs.find((m) => m.name === motorName);
225
+ if (motorConfig && targetPosition !== motorConfig.currentPosition) {
226
+ writeMotorPosition(
227
+ this.port,
228
+ motorConfig.id,
229
+ Math.round(targetPosition)
230
+ )
231
+ .then(() => {
232
+ motorConfig.currentPosition = targetPosition;
233
+ })
234
+ .catch((error) => {
235
+ console.warn(
236
+ `Failed to write motor ${motorConfig.id} position:`,
237
+ error
238
+ );
239
+ });
240
+ }
241
+ });
242
+ }
243
+ }
packages/web/src/types/teleoperation.ts CHANGED
@@ -2,6 +2,9 @@
2
  * Teleoperation-related types for web implementation
3
  */
4
 
 
 
 
5
  /**
6
  * Motor position and limits for teleoperation
7
  */
@@ -20,7 +23,11 @@ export interface TeleoperationState {
20
  isActive: boolean;
21
  motorConfigs: MotorConfig[];
22
  lastUpdate: number;
23
- keyStates: { [key: string]: { pressed: boolean; timestamp: number } };
 
 
 
 
24
  }
25
 
26
  /**
@@ -31,9 +38,70 @@ export interface TeleoperationProcess {
31
  stop(): void;
32
  updateKeyState(key: string, pressed: boolean): void;
33
  getState(): TeleoperationState;
34
- moveMotor(motorName: string, position: number): Promise<boolean>;
35
- setMotorPositions(positions: {
36
- [motorName: string]: number;
37
- }): Promise<boolean>;
38
  disconnect(): Promise<void>;
39
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  * Teleoperation-related types for web implementation
3
  */
4
 
5
+ import type { RobotConnection } from "./robot-connection.js";
6
+ import type { WebTeleoperator } from "../teleoperators/index.js";
7
+
8
  /**
9
  * Motor position and limits for teleoperation
10
  */
 
23
  isActive: boolean;
24
  motorConfigs: MotorConfig[];
25
  lastUpdate: number;
26
+
27
+ // Teleoperator-specific state (optional fields for different types)
28
+ keyStates?: { [key: string]: { pressed: boolean; timestamp: number } }; // keyboard
29
+ leaderPositions?: { [motor: string]: number }; // leader arm
30
+ gamepadState?: { axes: number[]; buttons: boolean[] }; // gamepad
31
  }
32
 
33
  /**
 
38
  stop(): void;
39
  updateKeyState(key: string, pressed: boolean): void;
40
  getState(): TeleoperationState;
41
+ teleoperator: WebTeleoperator;
 
 
 
42
  disconnect(): Promise<void>;
43
  }
44
+
45
+ /**
46
+ * Base interface for all teleoperator configurations
47
+ */
48
+ export interface BaseTeleoperatorConfig {
49
+ type: string;
50
+ }
51
+
52
+ /**
53
+ * Keyboard teleoperator configuration
54
+ */
55
+ export interface KeyboardTeleoperatorConfig extends BaseTeleoperatorConfig {
56
+ type: "keyboard";
57
+ stepSize?: number; // Default: KEYBOARD_TELEOPERATOR_DEFAULTS.stepSize
58
+ updateRate?: number; // Default: KEYBOARD_TELEOPERATOR_DEFAULTS.updateRate
59
+ keyTimeout?: number; // Default: KEYBOARD_TELEOPERATOR_DEFAULTS.keyTimeout
60
+ }
61
+
62
+ /**
63
+ * Leader arm teleoperator configuration (future)
64
+ */
65
+ export interface LeaderArmTeleoperatorConfig extends BaseTeleoperatorConfig {
66
+ type: "so100_leader";
67
+ port: string;
68
+ calibrationData?: any;
69
+ positionSmoothing?: boolean;
70
+ scaleFactor?: number;
71
+ }
72
+
73
+ /**
74
+ * Direct teleoperator configuration
75
+ */
76
+ export interface DirectTeleoperatorConfig extends BaseTeleoperatorConfig {
77
+ type: "direct";
78
+ }
79
+
80
+ /**
81
+ * Gamepad teleoperator configuration (future)
82
+ */
83
+ export interface GamepadTeleoperatorConfig extends BaseTeleoperatorConfig {
84
+ type: "gamepad";
85
+ controllerIndex?: number;
86
+ axisMapping?: { [axis: string]: string };
87
+ deadzone?: number;
88
+ }
89
+
90
+ /**
91
+ * Union type for all teleoperator configurations
92
+ */
93
+ export type TeleoperatorConfig =
94
+ | KeyboardTeleoperatorConfig
95
+ | LeaderArmTeleoperatorConfig
96
+ | DirectTeleoperatorConfig
97
+ | GamepadTeleoperatorConfig;
98
+
99
+ /**
100
+ * Main teleoperation configuration
101
+ */
102
+ export interface TeleoperateConfig {
103
+ robot: RobotConnection;
104
+ teleop: TeleoperatorConfig;
105
+ calibrationData?: { [motorName: string]: any };
106
+ onStateUpdate?: (state: TeleoperationState) => void;
107
+ }
pnpm-lock.yaml CHANGED
@@ -9,8 +9,8 @@ importers:
9
  .:
10
  dependencies:
11
  '@lerobot/web':
12
- specifier: ^0.1.1
13
- version: 0.1.1([email protected])
14
  '@radix-ui/react-dialog':
15
  specifier: ^1.1.14
16
@@ -402,11 +402,6 @@ packages:
402
  '@jridgewell/[email protected]':
403
  resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
404
 
405
- '@lerobot/[email protected]':
406
- resolution: {integrity: sha512-8xLGBTIQQetJzqauM9OtiSUIaSLphCH2qAqiGVzszmJk7pAcucqouezcIGRHiDOVRFDfpAYerRyfoeFLdoKqDQ==}
407
- peerDependencies:
408
- typescript: '>=4.5.0'
409
-
410
  '@manypkg/[email protected]':
411
  resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==}
412
 
@@ -2085,10 +2080,6 @@ snapshots:
2085
  '@jridgewell/resolve-uri': 3.1.2
2086
  '@jridgewell/sourcemap-codec': 1.5.0
2087
 
2088
2089
- dependencies:
2090
- typescript: 5.8.3
2091
-
2092
  '@manypkg/[email protected]':
2093
  dependencies:
2094
  '@babel/runtime': 7.27.6
 
9
  .:
10
  dependencies:
11
  '@lerobot/web':
12
+ specifier: workspace:*
13
+ version: link:packages/web
14
  '@radix-ui/react-dialog':
15
  specifier: ^1.1.14
16
 
402
  '@jridgewell/[email protected]':
403
  resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
404
 
 
 
 
 
 
405
  '@manypkg/[email protected]':
406
  resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==}
407
 
 
2080
  '@jridgewell/resolve-uri': 3.1.2
2081
  '@jridgewell/sourcemap-codec': 1.5.0
2082
 
 
 
 
 
2083
  '@manypkg/[email protected]':
2084
  dependencies:
2085
  '@babel/runtime': 7.27.6