NERDDISCO commited on
Commit
ba80dbe
·
1 Parent(s): ea49c9f

chore: recovered node

Browse files
src/lerobot/node/calibrate.ts CHANGED
@@ -1,15 +1,12 @@
1
  /**
2
  * Helper to recalibrate your device (robot or teleoperator).
3
  *
4
- * Direct port of Python lerobot calibrate.py
5
- *
6
  * Example:
7
  * ```
8
  * npx lerobot calibrate --robot.type=so100_follower --robot.port=COM4 --robot.id=my_follower_arm
9
  * ```
10
  */
11
 
12
- import type { CalibrateConfig } from "./robots/config.js";
13
  import { createSO100Follower } from "./robots/so100_follower.js";
14
  import { createSO100Leader } from "./teleoperators/so100_leader.js";
15
  import {
@@ -18,8 +15,9 @@ import {
18
  performInteractiveCalibration,
19
  setMotorLimits,
20
  verifyCalibration,
21
- type CalibrationResults,
22
  } from "./common/calibration.js";
 
 
23
  import { getSO100Config } from "./common/so100_config.js";
24
 
25
  /**
 
1
  /**
2
  * Helper to recalibrate your device (robot or teleoperator).
3
  *
 
 
4
  * Example:
5
  * ```
6
  * npx lerobot calibrate --robot.type=so100_follower --robot.port=COM4 --robot.id=my_follower_arm
7
  * ```
8
  */
9
 
 
10
  import { createSO100Follower } from "./robots/so100_follower.js";
11
  import { createSO100Leader } from "./teleoperators/so100_leader.js";
12
  import {
 
15
  performInteractiveCalibration,
16
  setMotorLimits,
17
  verifyCalibration,
 
18
  } from "./common/calibration.js";
19
+ import type { CalibrateConfig } from "./types/robot-config.js";
20
+ import type { CalibrationResults } from "./types/calibration.js";
21
  import { getSO100Config } from "./common/so100_config.js";
22
 
23
  /**
src/lerobot/node/common/calibration.ts CHANGED
@@ -11,34 +11,53 @@ import { SerialPort } from "serialport";
11
  import logUpdate from "log-update";
12
 
13
  /**
14
- * SO-100 device configuration for calibration
 
15
  */
16
- export interface SO100CalibrationConfig {
17
- deviceType: "so100_follower" | "so100_leader";
18
- port: SerialPort;
19
- motorNames: string[];
20
- driveModes: number[];
21
- calibModes: string[];
22
- limits: {
23
- position_min: number[];
24
- position_max: number[];
25
- velocity_max: number[];
26
- torque_max: number[];
27
- };
 
 
 
 
 
 
28
  }
29
 
30
  /**
31
- * Calibration results structure matching Python lerobot format
 
32
  */
33
- export interface CalibrationResults {
34
- homing_offset: number[];
35
- drive_mode: number[];
36
- start_pos: number[];
37
- end_pos: number[];
38
- calib_mode: string[];
39
- motor_names: string[];
 
40
  }
41
 
 
 
 
 
 
 
 
 
 
 
42
  /**
43
  * Initialize device communication
44
  * Common for both SO-100 leader and follower (same hardware)
@@ -80,32 +99,38 @@ export async function initializeDeviceCommunication(
80
 
81
  /**
82
  * Read current motor positions
83
- * Uses STS3215 protocol - same for all SO-100 devices
84
  */
85
  export async function readMotorPositions(
86
  config: SO100CalibrationConfig,
87
  quiet: boolean = false
88
  ): Promise<number[]> {
89
  const motorPositions: number[] = [];
90
- const motorIds = [1, 2, 3, 4, 5, 6]; // SO-100 uses servo IDs 1-6
91
 
92
- for (let i = 0; i < motorIds.length; i++) {
93
- const motorId = motorIds[i];
94
  const motorName = config.motorNames[i];
95
 
96
  try {
97
- // Create STS3215 Read Position packet
98
  const packet = Buffer.from([
99
  0xff,
100
  0xff,
101
  motorId,
102
  0x04,
103
  0x02,
104
- 0x38,
105
  0x02,
106
  0x00,
107
  ]);
108
- const checksum = ~(motorId + 0x04 + 0x02 + 0x38 + 0x02) & 0xff;
 
 
 
 
 
 
 
109
  packet[7] = checksum;
110
 
111
  if (!config.port || !config.port.isOpen) {
@@ -131,16 +156,19 @@ export async function readMotorPositions(
131
  const position = response[5] | (response[6] << 8);
132
  motorPositions.push(position);
133
  } else {
134
- motorPositions.push(2047); // Fallback to center
 
 
 
135
  }
136
  } else {
137
- motorPositions.push(2047);
138
  }
139
  } catch (readError) {
140
- motorPositions.push(2047);
141
  }
142
  } catch (error) {
143
- motorPositions.push(2047);
144
  }
145
 
146
  // Minimal delay between servo reads for 30Hz performance
@@ -158,7 +186,6 @@ export async function performInteractiveCalibration(
158
  config: SO100CalibrationConfig
159
  ): Promise<CalibrationResults> {
160
  // Step 1: Set homing position
161
- console.log("📍 STEP 1: Set Homing Position");
162
  await promptUser(
163
  `Move the SO-100 ${config.deviceType} to the MIDDLE of its range of motion and press ENTER...`
164
  );
@@ -166,18 +193,30 @@ export async function performInteractiveCalibration(
166
  const homingOffsets = await setHomingOffsets(config);
167
 
168
  // Step 2: Record ranges of motion with live updates
169
- console.log("\n📏 STEP 2: Record Joint Ranges");
170
  const { rangeMins, rangeMaxes } = await recordRangesOfMotion(config);
171
 
172
- // Compile results silently
173
- const results: CalibrationResults = {
174
- homing_offset: config.motorNames.map((name) => homingOffsets[name]),
175
- drive_mode: config.driveModes,
176
- start_pos: config.motorNames.map((name) => rangeMins[name]),
177
- end_pos: config.motorNames.map((name) => rangeMaxes[name]),
178
- calib_mode: config.calibModes,
179
- motor_names: config.motorNames,
180
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
181
 
182
  return results;
183
  }
@@ -200,26 +239,204 @@ export async function verifyCalibration(
200
  // Silent unless error - calibration verification passed internally
201
  }
202
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  /**
204
  * Record homing offsets (current positions as center)
205
  * Mirrors Python bus.set_half_turn_homings()
 
 
 
206
  */
207
  async function setHomingOffsets(
208
  config: SO100CalibrationConfig
209
  ): Promise<{ [motor: string]: number }> {
 
 
 
 
 
 
 
210
  const currentPositions = await readMotorPositions(config);
211
  const homingOffsets: { [motor: string]: number } = {};
212
 
213
  for (let i = 0; i < config.motorNames.length; i++) {
214
  const motorName = config.motorNames[i];
215
  const position = currentPositions[i];
216
- const maxRes = 4095; // STS3215 resolution
217
- homingOffsets[motorName] = position - Math.floor(maxRes / 2);
 
 
218
  }
219
 
 
 
 
 
220
  return homingOffsets;
221
  }
222
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
  /**
224
  * Record ranges of motion with live updating table
225
  * Mirrors Python bus.record_ranges_of_motion()
@@ -228,7 +445,6 @@ async function recordRangesOfMotion(config: SO100CalibrationConfig): Promise<{
228
  rangeMins: { [motor: string]: number };
229
  rangeMaxes: { [motor: string]: number };
230
  }> {
231
- console.log("\n=== RECORDING RANGES OF MOTION ===");
232
  console.log(
233
  "Move all joints sequentially through their entire ranges of motion."
234
  );
@@ -239,13 +455,16 @@ async function recordRangesOfMotion(config: SO100CalibrationConfig): Promise<{
239
  const rangeMins: { [motor: string]: number } = {};
240
  const rangeMaxes: { [motor: string]: number } = {};
241
 
242
- // Initialize with current positions
243
- const initialPositions = await readMotorPositions(config);
 
 
 
244
  for (let i = 0; i < config.motorNames.length; i++) {
245
  const motorName = config.motorNames[i];
246
- const position = initialPositions[i];
247
- rangeMins[motorName] = position;
248
- rangeMaxes[motorName] = position;
249
  }
250
 
251
  let recording = true;
@@ -262,9 +481,6 @@ async function recordRangesOfMotion(config: SO100CalibrationConfig): Promise<{
262
  rl.close();
263
  });
264
 
265
- console.log("Recording started... (move the robot joints now)");
266
- console.log("Live table will appear below - values update in real time!\n");
267
-
268
  // Continuous recording loop with live updates - THE LIVE UPDATING TABLE!
269
  while (recording) {
270
  try {
@@ -287,8 +503,7 @@ async function recordRangesOfMotion(config: SO100CalibrationConfig): Promise<{
287
  // Show real-time feedback every 3 reads for faster updates - LIVE TABLE UPDATE
288
  if (readCount % 3 === 0) {
289
  // Build the live table content
290
- let liveTable = "=== LIVE POSITION RECORDING ===\n";
291
- liveTable += `Readings: ${readCount} | Press ENTER to stop\n\n`;
292
  liveTable += "Motor Name Current Min Max Range\n";
293
  liveTable += "─".repeat(55) + "\n";
294
 
@@ -366,3 +581,114 @@ async function readData(
366
  });
367
  });
368
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  import logUpdate from "log-update";
12
 
13
  /**
14
+ * Sign-magnitude encoding functions for Feetech STS3215 motors
15
+ * Mirrors Python lerobot/common/utils/encoding_utils.py
16
  */
17
+
18
+ /**
19
+ * Encode a signed integer using sign-magnitude format
20
+ * Bit at sign_bit_index represents sign (0=positive, 1=negative)
21
+ * Lower bits represent magnitude
22
+ */
23
+ function encodeSignMagnitude(value: number, signBitIndex: number): number {
24
+ const maxMagnitude = (1 << signBitIndex) - 1;
25
+ const magnitude = Math.abs(value);
26
+
27
+ if (magnitude > maxMagnitude) {
28
+ throw new Error(
29
+ `Magnitude ${magnitude} exceeds ${maxMagnitude} (max for signBitIndex=${signBitIndex})`
30
+ );
31
+ }
32
+
33
+ const directionBit = value < 0 ? 1 : 0;
34
+ return (directionBit << signBitIndex) | magnitude;
35
  }
36
 
37
  /**
38
+ * Decode a sign-magnitude encoded value back to signed integer
39
+ * Extracts sign bit and magnitude, then applies sign
40
  */
41
+ function decodeSignMagnitude(
42
+ encodedValue: number,
43
+ signBitIndex: number
44
+ ): number {
45
+ const directionBit = (encodedValue >> signBitIndex) & 1;
46
+ const magnitudeMask = (1 << signBitIndex) - 1;
47
+ const magnitude = encodedValue & magnitudeMask;
48
+ return directionBit ? -magnitude : magnitude;
49
  }
50
 
51
+ /**
52
+ * Device configuration for calibration
53
+ * Despite the "SO100" name, this interface is now device-agnostic and configurable
54
+ * for any robot using similar serial protocols (Feetech STS3215, etc.)
55
+ */
56
+ import type {
57
+ SO100CalibrationConfig,
58
+ CalibrationResults,
59
+ } from "../types/calibration.js";
60
+
61
  /**
62
  * Initialize device communication
63
  * Common for both SO-100 leader and follower (same hardware)
 
99
 
100
  /**
101
  * Read current motor positions
102
+ * Uses device-specific protocol - configurable for different robot types
103
  */
104
  export async function readMotorPositions(
105
  config: SO100CalibrationConfig,
106
  quiet: boolean = false
107
  ): Promise<number[]> {
108
  const motorPositions: number[] = [];
 
109
 
110
+ for (let i = 0; i < config.motorIds.length; i++) {
111
+ const motorId = config.motorIds[i];
112
  const motorName = config.motorNames[i];
113
 
114
  try {
115
+ // Create Read Position packet using configurable address
116
  const packet = Buffer.from([
117
  0xff,
118
  0xff,
119
  motorId,
120
  0x04,
121
  0x02,
122
+ config.protocol.presentPositionAddress, // Configurable address instead of hardcoded 0x38
123
  0x02,
124
  0x00,
125
  ]);
126
+ const checksum =
127
+ ~(
128
+ motorId +
129
+ 0x04 +
130
+ 0x02 +
131
+ config.protocol.presentPositionAddress +
132
+ 0x02
133
+ ) & 0xff;
134
  packet[7] = checksum;
135
 
136
  if (!config.port || !config.port.isOpen) {
 
156
  const position = response[5] | (response[6] << 8);
157
  motorPositions.push(position);
158
  } else {
159
+ // Use half of max resolution as fallback instead of hardcoded 2047
160
+ motorPositions.push(
161
+ Math.floor((config.protocol.resolution - 1) / 2)
162
+ );
163
  }
164
  } else {
165
+ motorPositions.push(Math.floor((config.protocol.resolution - 1) / 2));
166
  }
167
  } catch (readError) {
168
+ motorPositions.push(Math.floor((config.protocol.resolution - 1) / 2));
169
  }
170
  } catch (error) {
171
+ motorPositions.push(Math.floor((config.protocol.resolution - 1) / 2));
172
  }
173
 
174
  // Minimal delay between servo reads for 30Hz performance
 
186
  config: SO100CalibrationConfig
187
  ): Promise<CalibrationResults> {
188
  // Step 1: Set homing position
 
189
  await promptUser(
190
  `Move the SO-100 ${config.deviceType} to the MIDDLE of its range of motion and press ENTER...`
191
  );
 
193
  const homingOffsets = await setHomingOffsets(config);
194
 
195
  // Step 2: Record ranges of motion with live updates
 
196
  const { rangeMins, rangeMaxes } = await recordRangesOfMotion(config);
197
 
198
+ // Step 3: Set special range for wrist_roll (full turn motor)
199
+ rangeMins["wrist_roll"] = 0;
200
+ rangeMaxes["wrist_roll"] = 4095;
201
+
202
+ // Step 4: Write hardware position limits to motors (matching Python behavior)
203
+ await writeHardwarePositionLimits(config, rangeMins, rangeMaxes);
204
+
205
+ // Compile results in Python-compatible format
206
+ const results: CalibrationResults = {};
207
+
208
+ for (let i = 0; i < config.motorNames.length; i++) {
209
+ const motorName = config.motorNames[i];
210
+ const motorId = config.motorIds[i];
211
+
212
+ results[motorName] = {
213
+ id: motorId,
214
+ drive_mode: config.driveModes[i],
215
+ homing_offset: homingOffsets[motorName],
216
+ range_min: rangeMins[motorName],
217
+ range_max: rangeMaxes[motorName],
218
+ };
219
+ }
220
 
221
  return results;
222
  }
 
239
  // Silent unless error - calibration verification passed internally
240
  }
241
 
242
+ /**
243
+ * Reset homing offsets to 0 for all motors
244
+ * Mirrors Python reset_calibration() - critical step before calculating new offsets
245
+ * This ensures Present_Position reflects true physical position without existing offsets
246
+ */
247
+ async function resetHomingOffsets(
248
+ config: SO100CalibrationConfig
249
+ ): Promise<void> {
250
+ for (let i = 0; i < config.motorIds.length; i++) {
251
+ const motorId = config.motorIds[i];
252
+ const motorName = config.motorNames[i];
253
+
254
+ try {
255
+ // Write 0 to Homing_Offset register using configurable address
256
+ const homingOffsetValue = 0;
257
+
258
+ // Create Write Homing_Offset packet using configurable address
259
+ const packet = Buffer.from([
260
+ 0xff,
261
+ 0xff, // Header
262
+ motorId, // Servo ID
263
+ 0x05, // Length (Instruction + Address + Data + Checksum)
264
+ 0x03, // Instruction: WRITE_DATA
265
+ config.protocol.homingOffsetAddress, // Configurable address instead of hardcoded 0x1f
266
+ homingOffsetValue & 0xff, // Data_L (low byte)
267
+ (homingOffsetValue >> 8) & 0xff, // Data_H (high byte)
268
+ 0x00, // Checksum (will calculate)
269
+ ]);
270
+
271
+ // Calculate checksum using configurable address
272
+ const checksum =
273
+ ~(
274
+ motorId +
275
+ 0x05 +
276
+ 0x03 +
277
+ config.protocol.homingOffsetAddress +
278
+ (homingOffsetValue & 0xff) +
279
+ ((homingOffsetValue >> 8) & 0xff)
280
+ ) & 0xff;
281
+ packet[8] = checksum;
282
+
283
+ if (!config.port || !config.port.isOpen) {
284
+ throw new Error("Serial port not open");
285
+ }
286
+
287
+ // Send reset packet
288
+ await new Promise<void>((resolve, reject) => {
289
+ config.port.write(packet, (error) => {
290
+ if (error) {
291
+ reject(
292
+ new Error(
293
+ `Failed to reset homing offset for ${motorName}: ${error.message}`
294
+ )
295
+ );
296
+ } else {
297
+ resolve();
298
+ }
299
+ });
300
+ });
301
+
302
+ // Wait for response (silent unless error)
303
+ try {
304
+ await readData(config.port, 200);
305
+ } catch (error) {
306
+ // Silent - response not required for successful operation
307
+ }
308
+ } catch (error) {
309
+ throw new Error(
310
+ `Failed to reset homing offset for ${motorName}: ${
311
+ error instanceof Error ? error.message : error
312
+ }`
313
+ );
314
+ }
315
+
316
+ // Small delay between motor writes
317
+ await new Promise((resolve) => setTimeout(resolve, 20));
318
+ }
319
+ }
320
+
321
  /**
322
  * Record homing offsets (current positions as center)
323
  * Mirrors Python bus.set_half_turn_homings()
324
+ *
325
+ * CRITICAL: Must reset existing homing offsets to 0 first (like Python does)
326
+ * CRITICAL: Must WRITE the new homing offsets to motors immediately (like Python does)
327
  */
328
  async function setHomingOffsets(
329
  config: SO100CalibrationConfig
330
  ): Promise<{ [motor: string]: number }> {
331
+ // CRITICAL: Reset existing homing offsets to 0 first (matching Python)
332
+ await resetHomingOffsets(config);
333
+
334
+ // Wait a moment for reset to take effect
335
+ await new Promise((resolve) => setTimeout(resolve, 100));
336
+
337
+ // Now read positions (which will be true physical positions)
338
  const currentPositions = await readMotorPositions(config);
339
  const homingOffsets: { [motor: string]: number } = {};
340
 
341
  for (let i = 0; i < config.motorNames.length; i++) {
342
  const motorName = config.motorNames[i];
343
  const position = currentPositions[i];
344
+
345
+ // Generic formula: pos - int((max_res - 1) / 2) using configurable resolution
346
+ const halfTurn = Math.floor((config.protocol.resolution - 1) / 2);
347
+ homingOffsets[motorName] = position - halfTurn;
348
  }
349
 
350
+ // CRITICAL: Write homing offsets to motors immediately (matching Python exactly)
351
+ // Python does: for motor, offset in homing_offsets.items(): self.write("Homing_Offset", motor, offset)
352
+ await writeHomingOffsetsToMotors(config, homingOffsets);
353
+
354
  return homingOffsets;
355
  }
356
 
357
+ /**
358
+ * Write homing offsets to motor registers immediately
359
+ * Mirrors Python's immediate writing in set_half_turn_homings()
360
+ */
361
+ async function writeHomingOffsetsToMotors(
362
+ config: SO100CalibrationConfig,
363
+ homingOffsets: { [motor: string]: number }
364
+ ): Promise<void> {
365
+ for (let i = 0; i < config.motorIds.length; i++) {
366
+ const motorId = config.motorIds[i];
367
+ const motorName = config.motorNames[i];
368
+ const homingOffset = homingOffsets[motorName];
369
+
370
+ try {
371
+ // Encode using sign-magnitude format (like Python)
372
+ const encodedOffset = encodeSignMagnitude(
373
+ homingOffset,
374
+ config.protocol.signMagnitudeBit
375
+ );
376
+
377
+ // Create Write Homing_Offset packet
378
+ const packet = Buffer.from([
379
+ 0xff,
380
+ 0xff, // Header
381
+ motorId, // Servo ID
382
+ 0x05, // Length
383
+ 0x03, // Instruction: WRITE_DATA
384
+ config.protocol.homingOffsetAddress, // Homing_Offset address
385
+ encodedOffset & 0xff, // Data_L (low byte)
386
+ (encodedOffset >> 8) & 0xff, // Data_H (high byte)
387
+ 0x00, // Checksum (will calculate)
388
+ ]);
389
+
390
+ // Calculate checksum
391
+ const checksum =
392
+ ~(
393
+ motorId +
394
+ 0x05 +
395
+ 0x03 +
396
+ config.protocol.homingOffsetAddress +
397
+ (encodedOffset & 0xff) +
398
+ ((encodedOffset >> 8) & 0xff)
399
+ ) & 0xff;
400
+ packet[8] = checksum;
401
+
402
+ if (!config.port || !config.port.isOpen) {
403
+ throw new Error("Serial port not open");
404
+ }
405
+
406
+ // Send packet
407
+ await new Promise<void>((resolve, reject) => {
408
+ config.port.write(packet, (error) => {
409
+ if (error) {
410
+ reject(
411
+ new Error(
412
+ `Failed to write homing offset for ${motorName}: ${error.message}`
413
+ )
414
+ );
415
+ } else {
416
+ resolve();
417
+ }
418
+ });
419
+ });
420
+
421
+ // Wait for response (silent unless error)
422
+ try {
423
+ await readData(config.port, 200);
424
+ } catch (error) {
425
+ // Silent - response not required for successful operation
426
+ }
427
+ } catch (error) {
428
+ throw new Error(
429
+ `Failed to write homing offset for ${motorName}: ${
430
+ error instanceof Error ? error.message : error
431
+ }`
432
+ );
433
+ }
434
+
435
+ // Small delay between motor writes
436
+ await new Promise((resolve) => setTimeout(resolve, 20));
437
+ }
438
+ }
439
+
440
  /**
441
  * Record ranges of motion with live updating table
442
  * Mirrors Python bus.record_ranges_of_motion()
 
445
  rangeMins: { [motor: string]: number };
446
  rangeMaxes: { [motor: string]: number };
447
  }> {
 
448
  console.log(
449
  "Move all joints sequentially through their entire ranges of motion."
450
  );
 
455
  const rangeMins: { [motor: string]: number } = {};
456
  const rangeMaxes: { [motor: string]: number } = {};
457
 
458
+ // Read actual current positions (matching Python exactly)
459
+ // Python does: start_positions = self.sync_read("Present_Position", motors, normalize=False)
460
+ // mins = start_positions.copy(); maxes = start_positions.copy()
461
+ const startPositions = await readMotorPositions(config);
462
+
463
  for (let i = 0; i < config.motorNames.length; i++) {
464
  const motorName = config.motorNames[i];
465
+ const startPosition = startPositions[i];
466
+ rangeMins[motorName] = startPosition; // Use actual position, not hardcoded 2047
467
+ rangeMaxes[motorName] = startPosition; // Use actual position, not hardcoded 2047
468
  }
469
 
470
  let recording = true;
 
481
  rl.close();
482
  });
483
 
 
 
 
484
  // Continuous recording loop with live updates - THE LIVE UPDATING TABLE!
485
  while (recording) {
486
  try {
 
503
  // Show real-time feedback every 3 reads for faster updates - LIVE TABLE UPDATE
504
  if (readCount % 3 === 0) {
505
  // Build the live table content
506
+ let liveTable = `Readings: ${readCount}\n\n`;
 
507
  liveTable += "Motor Name Current Min Max Range\n";
508
  liveTable += "─".repeat(55) + "\n";
509
 
 
581
  });
582
  });
583
  }
584
+
585
+ /**
586
+ * Write hardware position limits to motors
587
+ * Mirrors Python lerobot write_calibration() behavior where it writes:
588
+ * - Min_Position_Limit register with calibration.range_min
589
+ * - Max_Position_Limit register with calibration.range_max
590
+ * This physically constrains the motors to the calibrated ranges
591
+ */
592
+ async function writeHardwarePositionLimits(
593
+ config: SO100CalibrationConfig,
594
+ rangeMins: { [motor: string]: number },
595
+ rangeMaxes: { [motor: string]: number }
596
+ ): Promise<void> {
597
+ for (let i = 0; i < config.motorIds.length; i++) {
598
+ const motorId = config.motorIds[i];
599
+ const motorName = config.motorNames[i];
600
+ const minLimit = rangeMins[motorName];
601
+ const maxLimit = rangeMaxes[motorName];
602
+
603
+ try {
604
+ // Write Min_Position_Limit register
605
+ await writeMotorRegister(
606
+ config,
607
+ motorId,
608
+ config.protocol.minPositionLimitAddress,
609
+ minLimit,
610
+ `Min_Position_Limit for ${motorName}`
611
+ );
612
+
613
+ // Small delay between writes
614
+ await new Promise((resolve) => setTimeout(resolve, 20));
615
+
616
+ // Write Max_Position_Limit register
617
+ await writeMotorRegister(
618
+ config,
619
+ motorId,
620
+ config.protocol.maxPositionLimitAddress,
621
+ maxLimit,
622
+ `Max_Position_Limit for ${motorName}`
623
+ );
624
+
625
+ // Small delay between motors
626
+ await new Promise((resolve) => setTimeout(resolve, 20));
627
+ } catch (error) {
628
+ throw new Error(
629
+ `Failed to write position limits for ${motorName}: ${
630
+ error instanceof Error ? error.message : error
631
+ }`
632
+ );
633
+ }
634
+ }
635
+ }
636
+
637
+ /**
638
+ * Generic function to write a 2-byte value to a motor register
639
+ * Used for both Min_Position_Limit and Max_Position_Limit
640
+ */
641
+ async function writeMotorRegister(
642
+ config: SO100CalibrationConfig,
643
+ motorId: number,
644
+ registerAddress: number,
645
+ value: number,
646
+ description: string
647
+ ): Promise<void> {
648
+ // Create Write Register packet
649
+ const packet = Buffer.from([
650
+ 0xff,
651
+ 0xff, // Header
652
+ motorId, // Servo ID
653
+ 0x05, // Length (Instruction + Address + Data + Checksum)
654
+ 0x03, // Instruction: WRITE_DATA
655
+ registerAddress, // Register address
656
+ value & 0xff, // Data_L (low byte)
657
+ (value >> 8) & 0xff, // Data_H (high byte)
658
+ 0x00, // Checksum (will calculate)
659
+ ]);
660
+
661
+ // Calculate checksum
662
+ const checksum =
663
+ ~(
664
+ motorId +
665
+ 0x05 +
666
+ 0x03 +
667
+ registerAddress +
668
+ (value & 0xff) +
669
+ ((value >> 8) & 0xff)
670
+ ) & 0xff;
671
+ packet[8] = checksum;
672
+
673
+ if (!config.port || !config.port.isOpen) {
674
+ throw new Error("Serial port not open");
675
+ }
676
+
677
+ // Send packet
678
+ await new Promise<void>((resolve, reject) => {
679
+ config.port.write(packet, (error) => {
680
+ if (error) {
681
+ reject(new Error(`Failed to write ${description}: ${error.message}`));
682
+ } else {
683
+ resolve();
684
+ }
685
+ });
686
+ });
687
+
688
+ // Wait for response (silent unless error)
689
+ try {
690
+ await readData(config.port, 200);
691
+ } catch (error) {
692
+ // Silent - response not required for successful operation
693
+ }
694
+ }
src/lerobot/node/common/so100_config.ts CHANGED
@@ -4,7 +4,7 @@
4
  * Mirrors Python lerobot device configuration approach
5
  */
6
 
7
- import type { SO100CalibrationConfig } from "./calibration.js";
8
  import { SerialPort } from "serialport";
9
 
10
  /**
@@ -19,10 +19,48 @@ const SO100_MOTOR_NAMES = [
19
  "gripper",
20
  ];
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  /**
23
  * SO-100 Follower Configuration
24
  * Robot arm that performs tasks autonomously
25
- * Uses standard gear ratios for all motors
26
  */
27
  export function createSO100FollowerConfig(
28
  port: SerialPort
@@ -31,19 +69,21 @@ export function createSO100FollowerConfig(
31
  deviceType: "so100_follower",
32
  port,
33
  motorNames: SO100_MOTOR_NAMES,
 
 
34
 
35
- // Follower uses standard drive modes (all same gear ratio)
36
- driveModes: [0, 0, 0, 0, 0, 0], // All 1/345 gear ratio
37
 
38
- // Calibration modes
39
  calibModes: ["DEGREE", "DEGREE", "DEGREE", "DEGREE", "DEGREE", "LINEAR"],
40
 
41
- // Follower limits - optimized for autonomous operation
42
  limits: {
43
  position_min: [-180, -90, -90, -90, -90, -90],
44
  position_max: [180, 90, 90, 90, 90, 90],
45
- velocity_max: [100, 100, 100, 100, 100, 100], // Fast for autonomous tasks
46
- torque_max: [50, 50, 50, 50, 25, 25], // Higher torque for carrying loads
47
  },
48
  };
49
  }
@@ -51,7 +91,7 @@ export function createSO100FollowerConfig(
51
  /**
52
  * SO-100 Leader Configuration
53
  * Teleoperator arm that humans use to control the follower
54
- * Uses mixed gear ratios for easier human operation
55
  */
56
  export function createSO100LeaderConfig(
57
  port: SerialPort
@@ -60,20 +100,21 @@ export function createSO100LeaderConfig(
60
  deviceType: "so100_leader",
61
  port,
62
  motorNames: SO100_MOTOR_NAMES,
 
 
63
 
64
- // Leader uses mixed gear ratios for easier human operation
65
- // Based on Python lerobot leader calibration data
66
- driveModes: [0, 1, 0, 0, 1, 0], // Mixed ratios: some 1/345, some 1/191, some 1/147
67
 
68
  // Same calibration modes as follower
69
  calibModes: ["DEGREE", "DEGREE", "DEGREE", "DEGREE", "DEGREE", "LINEAR"],
70
 
71
- // Leader limits - optimized for human operation (safer, easier to move)
72
  limits: {
73
  position_min: [-120, -60, -60, -60, -180, -45],
74
  position_max: [120, 60, 60, 60, 180, 45],
75
- velocity_max: [80, 80, 80, 80, 120, 60], // Slower for human control
76
- torque_max: [30, 30, 30, 30, 20, 15], // Lower torque for safety
77
  },
78
  };
79
  }
 
4
  * Mirrors Python lerobot device configuration approach
5
  */
6
 
7
+ import type { SO100CalibrationConfig } from "../types/calibration.js";
8
  import { SerialPort } from "serialport";
9
 
10
  /**
 
19
  "gripper",
20
  ];
21
 
22
+ /**
23
+ * Common motor IDs for all SO-100 devices (STS3215 servos)
24
+ */
25
+ const SO100_MOTOR_IDS = [1, 2, 3, 4, 5, 6];
26
+
27
+ /**
28
+ * Protocol configuration for STS3215 motors used in SO-100 devices
29
+ */
30
+ interface STS3215Protocol {
31
+ resolution: number;
32
+ homingOffsetAddress: number;
33
+ homingOffsetLength: number;
34
+ presentPositionAddress: number;
35
+ presentPositionLength: number;
36
+ minPositionLimitAddress: number;
37
+ minPositionLimitLength: number;
38
+ maxPositionLimitAddress: number;
39
+ maxPositionLimitLength: number;
40
+ signMagnitudeBit: number; // Bit 11 is sign bit for Homing_Offset encoding
41
+ }
42
+
43
+ /**
44
+ * STS3215 Protocol Configuration
45
+ * These addresses and settings are specific to the STS3215 servo motors
46
+ */
47
+ export const STS3215_PROTOCOL: STS3215Protocol = {
48
+ resolution: 4096, // 12-bit resolution (0-4095)
49
+ homingOffsetAddress: 31, // Address for Homing_Offset register
50
+ homingOffsetLength: 2, // 2 bytes for Homing_Offset
51
+ presentPositionAddress: 56, // Address for Present_Position register
52
+ presentPositionLength: 2, // 2 bytes for Present_Position
53
+ minPositionLimitAddress: 9, // Address for Min_Position_Limit register
54
+ minPositionLimitLength: 2, // 2 bytes for Min_Position_Limit
55
+ maxPositionLimitAddress: 11, // Address for Max_Position_Limit register
56
+ maxPositionLimitLength: 2, // 2 bytes for Max_Position_Limit
57
+ signMagnitudeBit: 11, // Bit 11 is sign bit for Homing_Offset encoding
58
+ } as const;
59
+
60
  /**
61
  * SO-100 Follower Configuration
62
  * Robot arm that performs tasks autonomously
63
+ * Drive modes match Python lerobot exactly: all motors use drive_mode=0
64
  */
65
  export function createSO100FollowerConfig(
66
  port: SerialPort
 
69
  deviceType: "so100_follower",
70
  port,
71
  motorNames: SO100_MOTOR_NAMES,
72
+ motorIds: SO100_MOTOR_IDS,
73
+ protocol: STS3215_PROTOCOL,
74
 
75
+ // Python lerobot uses drive_mode=0 for all motors (current format)
76
+ driveModes: [0, 0, 0, 0, 0, 0],
77
 
78
+ // Calibration modes (not used in current implementation, but kept for compatibility)
79
  calibModes: ["DEGREE", "DEGREE", "DEGREE", "DEGREE", "DEGREE", "LINEAR"],
80
 
81
+ // Follower limits - these are not used in calibration file format
82
  limits: {
83
  position_min: [-180, -90, -90, -90, -90, -90],
84
  position_max: [180, 90, 90, 90, 90, 90],
85
+ velocity_max: [100, 100, 100, 100, 100, 100],
86
+ torque_max: [50, 50, 50, 50, 25, 25],
87
  },
88
  };
89
  }
 
91
  /**
92
  * SO-100 Leader Configuration
93
  * Teleoperator arm that humans use to control the follower
94
+ * Drive modes match Python lerobot exactly: all motors use drive_mode=0
95
  */
96
  export function createSO100LeaderConfig(
97
  port: SerialPort
 
100
  deviceType: "so100_leader",
101
  port,
102
  motorNames: SO100_MOTOR_NAMES,
103
+ motorIds: SO100_MOTOR_IDS,
104
+ protocol: STS3215_PROTOCOL,
105
 
106
+ // Python lerobot uses drive_mode=0 for all motors (current format)
107
+ driveModes: [0, 0, 0, 0, 0, 0],
 
108
 
109
  // Same calibration modes as follower
110
  calibModes: ["DEGREE", "DEGREE", "DEGREE", "DEGREE", "DEGREE", "LINEAR"],
111
 
112
+ // Leader limits - these are not used in calibration file format
113
  limits: {
114
  position_min: [-120, -60, -60, -60, -180, -45],
115
  position_max: [120, 60, 60, 60, 180, 45],
116
+ velocity_max: [80, 80, 80, 80, 120, 60],
117
+ torque_max: [30, 30, 30, 30, 20, 15],
118
  },
119
  };
120
  }
src/lerobot/node/find_port.ts CHANGED
@@ -1,8 +1,6 @@
1
  /**
2
  * Helper to find the USB port associated with your MotorsBus.
3
  *
4
- * Direct port of Python lerobot find_port.py
5
- *
6
  * Example:
7
  * ```
8
  * npx lerobot find-port
 
1
  /**
2
  * Helper to find the USB port associated with your MotorsBus.
3
  *
 
 
4
  * Example:
5
  * ```
6
  * npx lerobot find-port
src/lerobot/node/robots/robot.ts CHANGED
@@ -6,9 +6,10 @@
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";
12
 
13
  export abstract class Robot {
14
  protected port: SerialPort | null = null;
@@ -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 "../types/robot-config.js";
12
+ import { getCalibrationDir, ROBOTS } from "../utils/constants.js";
13
 
14
  export abstract class Robot {
15
  protected port: SerialPort | null = null;
 
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
@@ -4,7 +4,7 @@
4
  */
5
 
6
  import { Robot } from "./robot.js";
7
- import type { RobotConfig } from "./config.js";
8
  import * as readline from "readline";
9
 
10
  export class SO100Follower extends Robot {
@@ -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})`
 
4
  */
5
 
6
  import { Robot } from "./robot.js";
7
+ import type { RobotConfig } from "../types/robot-config.js";
8
  import * as readline from "readline";
9
 
10
  export class SO100Follower extends Robot {
 
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,316 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Robot teleoperation using keyboard control
3
+ *
4
+ * Example:
5
+ * ```
6
+ * npx lerobot teleoperate --robot.type=so100_follower --robot.port=COM4 --teleop.type=keyboard
7
+ * ```
8
+ */
9
+
10
+ import { createSO100Follower } from "./robots/so100_follower.js";
11
+ import { KeyboardController } from "./utils/keyboard-teleop.js";
12
+ import type { TeleoperateConfig } from "./types/teleoperation.js";
13
+
14
+ /**
15
+ * Main teleoperate function
16
+ * Mirrors Python lerobot teleoperate.py structure
17
+ */
18
+ export async function teleoperate(config: TeleoperateConfig): Promise<void> {
19
+ // Validate configuration
20
+ if (!config.robot) {
21
+ throw new Error("Robot configuration is required");
22
+ }
23
+
24
+ if (!config.teleop || config.teleop.type !== "keyboard") {
25
+ throw new Error("Only keyboard teleoperation is currently supported");
26
+ }
27
+
28
+ const stepSize = config.step_size || 25;
29
+ const duration = config.duration_s;
30
+
31
+ let robot;
32
+ let keyboardController;
33
+
34
+ try {
35
+ // Create robot
36
+ switch (config.robot.type) {
37
+ case "so100_follower":
38
+ robot = createSO100Follower(config.robot);
39
+ break;
40
+ default:
41
+ throw new Error(`Unsupported robot type: ${config.robot.type}`);
42
+ }
43
+
44
+ console.log(
45
+ `Connecting to robot: ${config.robot.type} on ${config.robot.port}`
46
+ );
47
+ if (config.robot.id) {
48
+ console.log(`Robot ID: ${config.robot.id}`);
49
+ }
50
+
51
+ await robot.connect(false); // calibrate=false
52
+ console.log("Robot connected successfully.");
53
+
54
+ // Show calibration status
55
+ const isCalibrated = (robot as any).isCalibrated;
56
+ if (isCalibrated) {
57
+ console.log(
58
+ `✅ Loaded calibration for: ${config.robot.id || config.robot.type}`
59
+ );
60
+ } else {
61
+ console.log(
62
+ `⚠️ No calibration found for: ${
63
+ config.robot.id || config.robot.type
64
+ } (using defaults)`
65
+ );
66
+ console.log(
67
+ " Run 'npx lerobot calibrate' first for optimal performance!"
68
+ );
69
+ }
70
+
71
+ // Create keyboard controller
72
+ keyboardController = new KeyboardController(robot, stepSize);
73
+
74
+ console.log("");
75
+ console.log("Starting keyboard teleoperation...");
76
+ console.log("Controls:");
77
+ console.log(" ↑↓ Arrow Keys: Shoulder Lift");
78
+ console.log(" ←→ Arrow Keys: Shoulder Pan");
79
+ console.log(" W/S: Elbow Flex");
80
+ console.log(" A/D: Wrist Flex");
81
+ console.log(" Q/E: Wrist Roll");
82
+ console.log(" Space: Gripper Toggle");
83
+ console.log(" ESC: Emergency Stop");
84
+ console.log(" Ctrl+C: Exit");
85
+ console.log("");
86
+ console.log("Press any control key to begin...");
87
+ console.log("");
88
+
89
+ // Start teleoperation control loop
90
+ await teleoperationLoop(keyboardController, robot, duration || null);
91
+ } catch (error) {
92
+ // Ensure we disconnect even if there's an error
93
+ if (keyboardController) {
94
+ try {
95
+ await keyboardController.stop();
96
+ } catch (stopError) {
97
+ console.warn("Warning: Failed to stop keyboard controller properly");
98
+ }
99
+ }
100
+ if (robot) {
101
+ try {
102
+ await robot.disconnect();
103
+ } catch (disconnectError) {
104
+ console.warn("Warning: Failed to disconnect robot properly");
105
+ }
106
+ }
107
+ throw error;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Main teleoperation control loop
113
+ */
114
+ async function teleoperationLoop(
115
+ keyboardController: KeyboardController,
116
+ robot: any,
117
+ duration: number | null
118
+ ): Promise<void> {
119
+ console.log("Initializing teleoperation...");
120
+
121
+ // Start keyboard controller
122
+ await keyboardController.start();
123
+
124
+ const startTime = performance.now();
125
+
126
+ // Set up graceful shutdown
127
+ let running = true;
128
+ process.on("SIGINT", () => {
129
+ console.log("\nShutting down gracefully...");
130
+ running = false;
131
+ });
132
+
133
+ try {
134
+ // Just wait for the keyboard controller to handle everything
135
+ while (running) {
136
+ // Check duration limit
137
+ if (duration && performance.now() - startTime >= duration * 1000) {
138
+ console.log(`\nDuration limit reached (${duration}s). Stopping...`);
139
+ break;
140
+ }
141
+
142
+ // Small delay to prevent busy waiting
143
+ await new Promise((resolve) => setTimeout(resolve, 100));
144
+ }
145
+ } finally {
146
+ console.log("\nStopping teleoperation...");
147
+ await keyboardController.stop();
148
+ await robot.disconnect();
149
+ console.log("Teleoperation stopped.");
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Parse command line arguments in Python argparse style
155
+ * Handles --robot.type=so100_follower --teleop.type=keyboard format
156
+ */
157
+ export function parseArgs(args: string[]): TeleoperateConfig {
158
+ const config: Partial<TeleoperateConfig> = {};
159
+
160
+ for (const arg of args) {
161
+ if (arg.startsWith("--robot.")) {
162
+ if (!config.robot) {
163
+ config.robot = { type: "so100_follower", port: "" };
164
+ }
165
+
166
+ const [key, value] = arg.substring(8).split("=");
167
+ switch (key) {
168
+ case "type":
169
+ if (value !== "so100_follower") {
170
+ throw new Error(`Unsupported robot type: ${value}`);
171
+ }
172
+ config.robot.type = value as "so100_follower";
173
+ break;
174
+ case "port":
175
+ config.robot.port = value;
176
+ break;
177
+ case "id":
178
+ config.robot.id = value;
179
+ break;
180
+ default:
181
+ throw new Error(`Unknown robot parameter: ${key}`);
182
+ }
183
+ } else if (arg.startsWith("--teleop.")) {
184
+ if (!config.teleop) {
185
+ config.teleop = { type: "keyboard" };
186
+ }
187
+
188
+ const [key, value] = arg.substring(9).split("=");
189
+ switch (key) {
190
+ case "type":
191
+ if (value !== "keyboard") {
192
+ throw new Error(`Unsupported teleoperator type: ${value}`);
193
+ }
194
+ config.teleop.type = value as "keyboard";
195
+ break;
196
+ default:
197
+ throw new Error(`Unknown teleoperator parameter: ${key}`);
198
+ }
199
+ } else if (arg.startsWith("--fps=")) {
200
+ config.fps = parseInt(arg.substring(6));
201
+ if (isNaN(config.fps) || config.fps <= 0) {
202
+ throw new Error("FPS must be a positive number");
203
+ }
204
+ } else if (arg.startsWith("--step_size=")) {
205
+ config.step_size = parseInt(arg.substring(12));
206
+ if (isNaN(config.step_size) || config.step_size <= 0) {
207
+ throw new Error("Step size must be a positive number");
208
+ }
209
+ } else if (arg.startsWith("--duration_s=")) {
210
+ config.duration_s = parseInt(arg.substring(13));
211
+ if (isNaN(config.duration_s) || config.duration_s <= 0) {
212
+ throw new Error("Duration must be a positive number");
213
+ }
214
+ } else if (arg === "--help" || arg === "-h") {
215
+ showUsage();
216
+ process.exit(0);
217
+ } else if (!arg.startsWith("--")) {
218
+ // Skip non-option arguments
219
+ continue;
220
+ } else {
221
+ throw new Error(`Unknown argument: ${arg}`);
222
+ }
223
+ }
224
+
225
+ // Validate required fields
226
+ if (!config.robot?.port) {
227
+ throw new Error("Robot port is required (--robot.port=PORT)");
228
+ }
229
+ if (!config.teleop?.type) {
230
+ throw new Error("Teleoperator type is required (--teleop.type=keyboard)");
231
+ }
232
+
233
+ return config as TeleoperateConfig;
234
+ }
235
+
236
+ /**
237
+ * Show usage information matching Python argparse output
238
+ */
239
+ function showUsage(): void {
240
+ console.log("Usage: lerobot teleoperate [options]");
241
+ console.log("");
242
+ console.log("Control a robot using keyboard input");
243
+ console.log("");
244
+ console.log("Options:");
245
+ console.log(" --robot.type=TYPE Robot type (so100_follower)");
246
+ console.log(
247
+ " --robot.port=PORT Robot serial port (e.g., COM4, /dev/ttyUSB0)"
248
+ );
249
+ console.log(" --robot.id=ID Robot identifier");
250
+ console.log(" --teleop.type=TYPE Teleoperator type (keyboard)");
251
+ console.log(
252
+ " --fps=FPS Control loop frame rate (default: 60)"
253
+ );
254
+ console.log(
255
+ " --step_size=SIZE Position step size per keypress (default: 10)"
256
+ );
257
+ console.log(" --duration_s=SECONDS Teleoperation duration in seconds");
258
+ console.log(" -h, --help Show this help message");
259
+ console.log("");
260
+ console.log("Keyboard Controls:");
261
+ console.log(" ↑↓ Arrow Keys Shoulder Lift");
262
+ console.log(" ←→ Arrow Keys Shoulder Pan");
263
+ console.log(" W/S Elbow Flex");
264
+ console.log(" A/D Wrist Flex");
265
+ console.log(" Q/E Wrist Roll");
266
+ console.log(" Space Gripper Toggle");
267
+ console.log(" ESC Emergency Stop");
268
+ console.log(" Ctrl+C Exit");
269
+ console.log("");
270
+ console.log("Examples:");
271
+ console.log(
272
+ " lerobot teleoperate --robot.type=so100_follower --robot.port=COM4 --teleop.type=keyboard"
273
+ );
274
+ console.log(
275
+ " lerobot teleoperate --robot.type=so100_follower --robot.port=COM4 --teleop.type=keyboard --fps=30 --step_size=50"
276
+ );
277
+ console.log("");
278
+ console.log("Use 'lerobot find-port' to discover available ports.");
279
+ }
280
+
281
+ /**
282
+ * CLI entry point when called directly
283
+ * Mirrors Python's if __name__ == "__main__": pattern
284
+ */
285
+ export async function main(args: string[]): Promise<void> {
286
+ try {
287
+ if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
288
+ showUsage();
289
+ return;
290
+ }
291
+
292
+ const config = parseArgs(args);
293
+ await teleoperate(config);
294
+ } catch (error) {
295
+ if (error instanceof Error) {
296
+ console.error("Error:", error.message);
297
+ } else {
298
+ console.error("Error:", error);
299
+ }
300
+
301
+ console.error("");
302
+ console.error("Please verify:");
303
+ console.error("1. The robot is connected to the specified port");
304
+ console.error("2. No other application is using the port");
305
+ console.error("3. You have permission to access the port");
306
+ console.error("");
307
+ console.error("Use 'lerobot find-port' to discover available ports.");
308
+
309
+ process.exit(1);
310
+ }
311
+ }
312
+
313
+ if (import.meta.url === `file://${process.argv[1]}`) {
314
+ const args = process.argv.slice(2);
315
+ main(args);
316
+ }
src/lerobot/node/teleoperators/so100_leader.ts CHANGED
@@ -6,7 +6,7 @@
6
  */
7
 
8
  import { Teleoperator } from "./teleoperator.js";
9
- import type { TeleoperatorConfig } from "./config.js";
10
 
11
  export class SO100Leader extends Teleoperator {
12
  constructor(config: TeleoperatorConfig) {
 
6
  */
7
 
8
  import { Teleoperator } from "./teleoperator.js";
9
+ import type { TeleoperatorConfig } from "../types/teleoperator-config.js";
10
 
11
  export class SO100Leader extends Teleoperator {
12
  constructor(config: TeleoperatorConfig) {
src/lerobot/node/teleoperators/teleoperator.ts CHANGED
@@ -7,8 +7,8 @@
7
  import { SerialPort } from "serialport";
8
  import { mkdir, writeFile } from "fs/promises";
9
  import { join } from "path";
10
- import type { TeleoperatorConfig } from "./config.js";
11
- import { getCalibrationDir, TELEOPERATORS } from "../constants.js";
12
 
13
  export abstract class Teleoperator {
14
  protected port: SerialPort | null = null;
 
7
  import { SerialPort } from "serialport";
8
  import { mkdir, writeFile } from "fs/promises";
9
  import { join } from "path";
10
+ import type { TeleoperatorConfig } from "../types/teleoperator-config.js";
11
+ import { getCalibrationDir, TELEOPERATORS } from "../utils/constants.js";
12
 
13
  export abstract class Teleoperator {
14
  protected port: SerialPort | null = null;
src/lerobot/node/types/calibration.ts ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Calibration types for Node.js implementation
3
+ */
4
+
5
+ import type { SerialPort } from "serialport";
6
+
7
+ export interface SO100CalibrationConfig {
8
+ deviceType: "so100_follower" | "so100_leader";
9
+ port: SerialPort;
10
+ motorNames: string[];
11
+ motorIds: number[]; // Device-specific motor IDs (e.g., [1,2,3,4,5,6] for SO-100)
12
+ driveModes: number[];
13
+ calibModes: string[];
14
+
15
+ // Protocol-specific configuration
16
+ protocol: {
17
+ resolution: number; // Motor resolution (e.g., 4096 for STS3215)
18
+ homingOffsetAddress: number; // Register address for homing offset (e.g., 31 for STS3215)
19
+ homingOffsetLength: number; // Length in bytes for homing offset register
20
+ presentPositionAddress: number; // Register address for present position (e.g., 56 for STS3215)
21
+ presentPositionLength: number; // Length in bytes for present position register
22
+ minPositionLimitAddress: number; // Register address for min position limit (e.g., 9 for STS3215)
23
+ minPositionLimitLength: number; // Length in bytes for min position limit register
24
+ maxPositionLimitAddress: number; // Register address for max position limit (e.g., 11 for STS3215)
25
+ maxPositionLimitLength: number; // Length in bytes for max position limit register
26
+ signMagnitudeBit: number; // Sign bit index for homing offset encoding (e.g., 11 for STS3215)
27
+ };
28
+
29
+ limits: {
30
+ position_min: number[];
31
+ position_max: number[];
32
+ velocity_max: number[];
33
+ torque_max: number[];
34
+ };
35
+ }
36
+
37
+ export interface CalibrationResults {
38
+ [motorName: string]: {
39
+ id: number;
40
+ drive_mode: number;
41
+ homing_offset: number;
42
+ range_min: number;
43
+ range_max: number;
44
+ };
45
+ }
src/lerobot/node/{robots/config.ts → types/robot-config.ts} RENAMED
@@ -1,10 +1,7 @@
1
  /**
2
- * Robot configuration types
3
- * Shared between Node.js and Web implementations
4
  */
5
 
6
- import type { TeleoperatorConfig } from "../teleoperators/config.js";
7
-
8
  export interface RobotConfig {
9
  type: "so100_follower";
10
  port: string;
@@ -20,3 +17,7 @@ export interface CalibrateConfig {
20
  robot?: RobotConfig;
21
  teleop?: TeleoperatorConfig;
22
  }
 
 
 
 
 
1
  /**
2
+ * Robot configuration types for Node.js implementation
 
3
  */
4
 
 
 
5
  export interface RobotConfig {
6
  type: "so100_follower";
7
  port: string;
 
17
  robot?: RobotConfig;
18
  teleop?: TeleoperatorConfig;
19
  }
20
+
21
+ // Re-export from teleoperator-config for convenience
22
+ import type { TeleoperatorConfig } from "./teleoperator-config.js";
23
+ export type { TeleoperatorConfig };
src/lerobot/node/types/teleoperation.ts ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Teleoperation types for Node.js implementation
3
+ */
4
+
5
+ import type { RobotConfig } from "./robot-config.js";
6
+
7
+ export interface TeleoperateConfig {
8
+ robot: RobotConfig;
9
+ teleop: KeyboardTeleoperationConfig;
10
+ fps?: number; // Default: 60
11
+ step_size?: number; // Default: 10 (motor position units)
12
+ duration_s?: number | null; // Default: null (infinite)
13
+ }
14
+
15
+ export interface KeyboardTeleoperationConfig {
16
+ type: "keyboard"; // Only keyboard for now, expandable later
17
+ }
src/lerobot/node/{teleoperators/config.ts → types/teleoperator-config.ts} RENAMED
@@ -1,6 +1,5 @@
1
  /**
2
- * Teleoperator configuration types
3
- * Shared between Node.js and Web implementations
4
  */
5
 
6
  export interface TeleoperatorConfig {
 
1
  /**
2
+ * Teleoperator configuration types for Node.js implementation
 
3
  */
4
 
5
  export interface TeleoperatorConfig {
src/lerobot/node/{constants.ts → utils/constants.ts} RENAMED
File without changes
src/lerobot/node/utils/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
+ }