NERDDISCO commited on
Commit
2386c32
·
1 Parent(s): 0d9f1af

fix: calibration in node

Browse files
docs/conventions.md CHANGED
@@ -205,3 +205,163 @@ lerobot/
205
  - **Baudrate Validation**: Only real devices will reveal communication problems
206
  - **User Flow Testing**: Test complete calibration workflows with actual hardware
207
  - **Port Management**: Ensure proper port cleanup between testing sessions
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
  - **Baudrate Validation**: Only real devices will reveal communication problems
206
  - **User Flow Testing**: Test complete calibration workflows with actual hardware
207
  - **Port Management**: Ensure proper port cleanup between testing sessions
208
+
209
+ ### CRITICAL: Calibration Implementation Requirements
210
+
211
+ #### Calibration File Format (Learned from SO-100 Implementation)
212
+
213
+ - **NEVER use array-based format**: Calibration files must use motor names as keys, NOT arrays
214
+ - **Python-Compatible Structure**: Each motor must be an object with `id`, `drive_mode`, `homing_offset`, `range_min`, `range_max`
215
+ - **Wrong Format** (causes Python incompatibility):
216
+ ```json
217
+ {
218
+ "homing_offset": [47, 1013, -957, ...],
219
+ "drive_mode": [0, 0, 0, ...],
220
+ "motor_names": ["shoulder_pan", ...]
221
+ }
222
+ ```
223
+ - **Correct Format** (Python-compatible):
224
+ ```json
225
+ {
226
+ "shoulder_pan": {
227
+ "id": 1,
228
+ "drive_mode": 0,
229
+ "homing_offset": 47,
230
+ "range_min": 985,
231
+ "range_max": 3085
232
+ }
233
+ }
234
+ ```
235
+
236
+ #### Homing Offset Calibration Protocol (Critical for STS3215/Feetech Motors)
237
+
238
+ - **MUST Reset Existing Offsets**: Before calculating new homing offsets, ALWAYS reset existing homing offsets to 0
239
+ - **Python Reference**: Python's `set_half_turn_homings()` calls `reset_calibration()` first
240
+ - **Missing Reset Causes**: Completely wrong homing offset values (~1000+ unit differences)
241
+ - **Reset Protocol**: Write value 0 to Homing_Offset register (address 31) for each motor before reading positions
242
+ - **Verification**: Ensure reset commands receive successful responses before proceeding
243
+
244
+ #### STS3215 Sign-Magnitude Encoding
245
+
246
+ - **Homing_Offset Uses Special Encoding**: Bit 11 is sign bit, lower 11 bits are magnitude
247
+ - **Position Reads**: Some registers may need sign-magnitude decoding - verify against Python behavior
248
+ - **Encoding Functions**: Implement `encodeSignMagnitude()` and `decodeSignMagnitude()` for protocol compatibility
249
+ - **Common Symptom**: Values differing by ~2048 or ~4096 indicate sign-magnitude encoding issues
250
+
251
+ #### Calibration Process Validation
252
+
253
+ - **Same Neutral Position**: When comparing calibrations, ensure robot is in identical physical position
254
+ - **Expected Accuracy**: Properly implemented calibration should match Python within 30 units
255
+ - **Debug Protocol**: Log position values, reset confirmations, and calculation steps for troubleshooting
256
+ - **Range Verification**: `wrist_roll` should always use full range (0-4095), other motors use recorded ranges
257
+
258
+ #### Common Calibration Mistakes to Avoid
259
+
260
+ 1. **Skipping Homing Reset**: Leads to ~1000+ unit differences in homing offsets
261
+ 2. **Array-Based File Format**: Makes calibration files incompatible with Python lerobot
262
+ 3. **Ignoring Sign-Magnitude Encoding**: Causes specific motors (often wrist_roll) to have wrong values
263
+ 4. **Different Physical Positions**: Comparing calibrations done at different robot positions
264
+ 5. **Missing Motor ID Assignment**: Forgetting to assign correct motor IDs (1-6 for SO-100)
265
+
266
+ #### Device-Agnostic Calibration Architecture
267
+
268
+ - **No Hardcoded Device Values**: Calibration logic must be configurable for different robot types
269
+ - **Configuration-Driven Protocol**: Motor IDs, register addresses, resolution, etc. should come from device config
270
+ - **Extensible Design**: Adding new robot types should only require new config files, not core logic changes
271
+ - **Example Bad Practice**: Hardcoding `const motorIds = [1,2,3,4,5,6]` in calibration logic
272
+ - **Example Good Practice**: Using `config.motorIds` from device-specific configuration
273
+ - **Protocol Abstraction**: Register addresses, resolution, encoding details should be configurable per device type
274
+
275
+ #### CRITICAL: Calibration Sequence and Hardware State Management
276
+
277
+ **The exact sequence of calibration operations is critical for Python compatibility. Getting this wrong causes major range/offset discrepancies.**
278
+
279
+ ##### The Correct Calibration Sequence (Matching Python Exactly)
280
+
281
+ 1. **Reset Existing Homing Offsets to 0**: Write 0 to all Homing_Offset registers
282
+ 2. **Read Physical Positions**: Get actual motor positions (will be raw, non-centered values)
283
+ 3. **Calculate New Homing Offsets**: `offset = position - (resolution-1)/2`
284
+ 4. **IMMEDIATELY Write Homing Offsets**: Write new offsets to motor registers **before range recording**
285
+ 5. **Read Positions for Range Init**: Now positions will appear centered (~2047) due to applied offsets
286
+ 6. **Record Range of Motion**: Use centered positions as starting min/max values
287
+ 7. **Write Hardware Position Limits**: Write `range_min`/`range_max` to motor limit registers
288
+
289
+ ##### Critical Implementation Details
290
+
291
+ **Homing Offset Writing Must Be Immediate:**
292
+
293
+ ```typescript
294
+ // WRONG - Only calculates, doesn't write to motors
295
+ async function setHomingOffsets(config) {
296
+ const positions = await readMotorPositions(config);
297
+ const offsets = calculateOffsets(positions);
298
+ return offsets; // ❌ Not written to motors!
299
+ }
300
+
301
+ // CORRECT - Writes offsets to motors immediately
302
+ async function setHomingOffsets(config) {
303
+ await resetHomingOffsets(config); // Reset first
304
+ const positions = await readMotorPositions(config);
305
+ const offsets = calculateOffsets(positions);
306
+ await writeHomingOffsetsToMotors(config, offsets); // ✅ Written immediately
307
+ return offsets;
308
+ }
309
+ ```
310
+
311
+ **Range Recording Initialization Must Read Actual Positions:**
312
+
313
+ ```typescript
314
+ // WRONG - Hardcoded center values
315
+ const rangeMins = {};
316
+ const rangeMaxes = {};
317
+ for (const motor of motors) {
318
+ rangeMins[motor] = 2047; // ❌ Hardcoded!
319
+ rangeMaxes[motor] = 2047;
320
+ }
321
+
322
+ // CORRECT - Read actual positions (now centered due to applied homing offsets)
323
+ const startPositions = await readMotorPositions(config);
324
+ const rangeMins = {};
325
+ const rangeMaxes = {};
326
+ for (let i = 0; i < motors.length; i++) {
327
+ rangeMins[motors[i]] = startPositions[i]; // ✅ Uses actual values
328
+ rangeMaxes[motors[i]] = startPositions[i];
329
+ }
330
+ ```
331
+
332
+ **Hardware Position Limits Must Be Written:**
333
+
334
+ ```typescript
335
+ // Python writes these registers, so we must too
336
+ await writeMotorRegister(config, motorId, MIN_POSITION_LIMIT_ADDR, range_min);
337
+ await writeMotorRegister(config, motorId, MAX_POSITION_LIMIT_ADDR, range_max);
338
+ ```
339
+
340
+ ##### Why This Sequence Matters
341
+
342
+ **Problem**: User moves robot to same physical position, but Python shows ~2047 and Node.js shows wildly different values (3013, 1200, etc.)
343
+
344
+ **Root Cause**: Python applies homing offsets immediately, making subsequent position reads appear centered. Node.js was calculating offsets but not applying them, so position reads remained raw.
345
+
346
+ **Evidence of Correct Implementation**: After fixing the sequence, Node.js and Python both show ~2047 for the same physical position, and final calibration ranges match within professional tolerances (±50 units).
347
+
348
+ ##### Register Addresses for STS3215 Motors
349
+
350
+ ```typescript
351
+ const STS3215_REGISTERS = {
352
+ Present_Position: { address: 56, length: 2 },
353
+ Homing_Offset: { address: 31, length: 2 }, // Sign-magnitude encoded
354
+ Min_Position_Limit: { address: 9, length: 2 },
355
+ Max_Position_Limit: { address: 11, length: 2 },
356
+ };
357
+ ```
358
+
359
+ ##### Common Sequence Mistakes That Cause Major Issues
360
+
361
+ 1. **Not Writing Homing Offsets**: Calculates but doesn't apply → position reads remain raw → wrong range initialization
362
+ 2. **Hardcoded Range Initialization**: Forces 2047 instead of reading actual positions → doesn't match Python behavior
363
+ 3. **Missing Hardware Limit Writing**: Python constrains motors, Node.js doesn't → different range recording behavior
364
+ 4. **Wrong Reset Timing**: Not resetting existing offsets first → accumulated offset errors
365
+ 5. **Skipping Intermediate Delays**: Not waiting for motor register writes to take effect → inconsistent state
366
+
367
+ **This sequence debugging took extensive analysis to solve. Future implementations MUST follow this exact pattern to maintain Python compatibility.**
src/lerobot/node/common/calibration.ts CHANGED
@@ -11,14 +11,70 @@ 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[];
@@ -29,14 +85,16 @@ export interface SO100CalibrationConfig {
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
  /**
@@ -80,32 +138,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 +195,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 +225,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 +232,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 +278,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 +484,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 +494,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 +520,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 +542,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 +620,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
  export interface SO100CalibrationConfig {
57
  deviceType: "so100_follower" | "so100_leader";
58
  port: SerialPort;
59
  motorNames: string[];
60
+ motorIds: number[]; // Device-specific motor IDs (e.g., [1,2,3,4,5,6] for SO-100)
61
  driveModes: number[];
62
  calibModes: string[];
63
+
64
+ // Protocol-specific configuration
65
+ protocol: {
66
+ resolution: number; // Motor resolution (e.g., 4096 for STS3215)
67
+ homingOffsetAddress: number; // Register address for homing offset (e.g., 31 for STS3215)
68
+ homingOffsetLength: number; // Length in bytes for homing offset register
69
+ presentPositionAddress: number; // Register address for present position (e.g., 56 for STS3215)
70
+ presentPositionLength: number; // Length in bytes for present position register
71
+ minPositionLimitAddress: number; // Register address for min position limit (e.g., 9 for STS3215)
72
+ minPositionLimitLength: number; // Length in bytes for min position limit register
73
+ maxPositionLimitAddress: number; // Register address for max position limit (e.g., 11 for STS3215)
74
+ maxPositionLimitLength: number; // Length in bytes for max position limit register
75
+ signMagnitudeBit: number; // Sign bit index for homing offset encoding (e.g., 11 for STS3215)
76
+ };
77
+
78
  limits: {
79
  position_min: number[];
80
  position_max: number[];
 
85
 
86
  /**
87
  * Calibration results structure matching Python lerobot format
88
+ * This should match the MotorCalibration dataclass structure in Python
89
  */
90
  export interface CalibrationResults {
91
+ [motorName: string]: {
92
+ id: number;
93
+ drive_mode: number;
94
+ homing_offset: number;
95
+ range_min: number;
96
+ range_max: number;
97
+ };
98
  }
99
 
100
  /**
 
138
 
139
  /**
140
  * Read current motor positions
141
+ * Uses device-specific protocol - configurable for different robot types
142
  */
143
  export async function readMotorPositions(
144
  config: SO100CalibrationConfig,
145
  quiet: boolean = false
146
  ): Promise<number[]> {
147
  const motorPositions: number[] = [];
 
148
 
149
+ for (let i = 0; i < config.motorIds.length; i++) {
150
+ const motorId = config.motorIds[i];
151
  const motorName = config.motorNames[i];
152
 
153
  try {
154
+ // Create Read Position packet using configurable address
155
  const packet = Buffer.from([
156
  0xff,
157
  0xff,
158
  motorId,
159
  0x04,
160
  0x02,
161
+ config.protocol.presentPositionAddress, // Configurable address instead of hardcoded 0x38
162
  0x02,
163
  0x00,
164
  ]);
165
+ const checksum =
166
+ ~(
167
+ motorId +
168
+ 0x04 +
169
+ 0x02 +
170
+ config.protocol.presentPositionAddress +
171
+ 0x02
172
+ ) & 0xff;
173
  packet[7] = checksum;
174
 
175
  if (!config.port || !config.port.isOpen) {
 
195
  const position = response[5] | (response[6] << 8);
196
  motorPositions.push(position);
197
  } else {
198
+ // Use half of max resolution as fallback instead of hardcoded 2047
199
+ motorPositions.push(
200
+ Math.floor((config.protocol.resolution - 1) / 2)
201
+ );
202
  }
203
  } else {
204
+ motorPositions.push(Math.floor((config.protocol.resolution - 1) / 2));
205
  }
206
  } catch (readError) {
207
+ motorPositions.push(Math.floor((config.protocol.resolution - 1) / 2));
208
  }
209
  } catch (error) {
210
+ motorPositions.push(Math.floor((config.protocol.resolution - 1) / 2));
211
  }
212
 
213
  // Minimal delay between servo reads for 30Hz performance
 
225
  config: SO100CalibrationConfig
226
  ): Promise<CalibrationResults> {
227
  // Step 1: Set homing position
 
228
  await promptUser(
229
  `Move the SO-100 ${config.deviceType} to the MIDDLE of its range of motion and press ENTER...`
230
  );
 
232
  const homingOffsets = await setHomingOffsets(config);
233
 
234
  // Step 2: Record ranges of motion with live updates
 
235
  const { rangeMins, rangeMaxes } = await recordRangesOfMotion(config);
236
 
237
+ // Step 3: Set special range for wrist_roll (full turn motor)
238
+ rangeMins["wrist_roll"] = 0;
239
+ rangeMaxes["wrist_roll"] = 4095;
240
+
241
+ // Step 4: Write hardware position limits to motors (matching Python behavior)
242
+ await writeHardwarePositionLimits(config, rangeMins, rangeMaxes);
243
+
244
+ // Compile results in Python-compatible format
245
+ const results: CalibrationResults = {};
246
+
247
+ for (let i = 0; i < config.motorNames.length; i++) {
248
+ const motorName = config.motorNames[i];
249
+ const motorId = config.motorIds[i];
250
+
251
+ results[motorName] = {
252
+ id: motorId,
253
+ drive_mode: config.driveModes[i],
254
+ homing_offset: homingOffsets[motorName],
255
+ range_min: rangeMins[motorName],
256
+ range_max: rangeMaxes[motorName],
257
+ };
258
+ }
259
 
260
  return results;
261
  }
 
278
  // Silent unless error - calibration verification passed internally
279
  }
280
 
281
+ /**
282
+ * Reset homing offsets to 0 for all motors
283
+ * Mirrors Python reset_calibration() - critical step before calculating new offsets
284
+ * This ensures Present_Position reflects true physical position without existing offsets
285
+ */
286
+ async function resetHomingOffsets(
287
+ config: SO100CalibrationConfig
288
+ ): Promise<void> {
289
+ for (let i = 0; i < config.motorIds.length; i++) {
290
+ const motorId = config.motorIds[i];
291
+ const motorName = config.motorNames[i];
292
+
293
+ try {
294
+ // Write 0 to Homing_Offset register using configurable address
295
+ const homingOffsetValue = 0;
296
+
297
+ // Create Write Homing_Offset packet using configurable address
298
+ const packet = Buffer.from([
299
+ 0xff,
300
+ 0xff, // Header
301
+ motorId, // Servo ID
302
+ 0x05, // Length (Instruction + Address + Data + Checksum)
303
+ 0x03, // Instruction: WRITE_DATA
304
+ config.protocol.homingOffsetAddress, // Configurable address instead of hardcoded 0x1f
305
+ homingOffsetValue & 0xff, // Data_L (low byte)
306
+ (homingOffsetValue >> 8) & 0xff, // Data_H (high byte)
307
+ 0x00, // Checksum (will calculate)
308
+ ]);
309
+
310
+ // Calculate checksum using configurable address
311
+ const checksum =
312
+ ~(
313
+ motorId +
314
+ 0x05 +
315
+ 0x03 +
316
+ config.protocol.homingOffsetAddress +
317
+ (homingOffsetValue & 0xff) +
318
+ ((homingOffsetValue >> 8) & 0xff)
319
+ ) & 0xff;
320
+ packet[8] = checksum;
321
+
322
+ if (!config.port || !config.port.isOpen) {
323
+ throw new Error("Serial port not open");
324
+ }
325
+
326
+ // Send reset packet
327
+ await new Promise<void>((resolve, reject) => {
328
+ config.port.write(packet, (error) => {
329
+ if (error) {
330
+ reject(
331
+ new Error(
332
+ `Failed to reset homing offset for ${motorName}: ${error.message}`
333
+ )
334
+ );
335
+ } else {
336
+ resolve();
337
+ }
338
+ });
339
+ });
340
+
341
+ // Wait for response (silent unless error)
342
+ try {
343
+ await readData(config.port, 200);
344
+ } catch (error) {
345
+ // Silent - response not required for successful operation
346
+ }
347
+ } catch (error) {
348
+ throw new Error(
349
+ `Failed to reset homing offset for ${motorName}: ${
350
+ error instanceof Error ? error.message : error
351
+ }`
352
+ );
353
+ }
354
+
355
+ // Small delay between motor writes
356
+ await new Promise((resolve) => setTimeout(resolve, 20));
357
+ }
358
+ }
359
+
360
  /**
361
  * Record homing offsets (current positions as center)
362
  * Mirrors Python bus.set_half_turn_homings()
363
+ *
364
+ * CRITICAL: Must reset existing homing offsets to 0 first (like Python does)
365
+ * CRITICAL: Must WRITE the new homing offsets to motors immediately (like Python does)
366
  */
367
  async function setHomingOffsets(
368
  config: SO100CalibrationConfig
369
  ): Promise<{ [motor: string]: number }> {
370
+ // CRITICAL: Reset existing homing offsets to 0 first (matching Python)
371
+ await resetHomingOffsets(config);
372
+
373
+ // Wait a moment for reset to take effect
374
+ await new Promise((resolve) => setTimeout(resolve, 100));
375
+
376
+ // Now read positions (which will be true physical positions)
377
  const currentPositions = await readMotorPositions(config);
378
  const homingOffsets: { [motor: string]: number } = {};
379
 
380
  for (let i = 0; i < config.motorNames.length; i++) {
381
  const motorName = config.motorNames[i];
382
  const position = currentPositions[i];
383
+
384
+ // Generic formula: pos - int((max_res - 1) / 2) using configurable resolution
385
+ const halfTurn = Math.floor((config.protocol.resolution - 1) / 2);
386
+ homingOffsets[motorName] = position - halfTurn;
387
  }
388
 
389
+ // CRITICAL: Write homing offsets to motors immediately (matching Python exactly)
390
+ // Python does: for motor, offset in homing_offsets.items(): self.write("Homing_Offset", motor, offset)
391
+ await writeHomingOffsetsToMotors(config, homingOffsets);
392
+
393
  return homingOffsets;
394
  }
395
 
396
+ /**
397
+ * Write homing offsets to motor registers immediately
398
+ * Mirrors Python's immediate writing in set_half_turn_homings()
399
+ */
400
+ async function writeHomingOffsetsToMotors(
401
+ config: SO100CalibrationConfig,
402
+ homingOffsets: { [motor: string]: number }
403
+ ): Promise<void> {
404
+ for (let i = 0; i < config.motorIds.length; i++) {
405
+ const motorId = config.motorIds[i];
406
+ const motorName = config.motorNames[i];
407
+ const homingOffset = homingOffsets[motorName];
408
+
409
+ try {
410
+ // Encode using sign-magnitude format (like Python)
411
+ const encodedOffset = encodeSignMagnitude(
412
+ homingOffset,
413
+ config.protocol.signMagnitudeBit
414
+ );
415
+
416
+ // Create Write Homing_Offset packet
417
+ const packet = Buffer.from([
418
+ 0xff,
419
+ 0xff, // Header
420
+ motorId, // Servo ID
421
+ 0x05, // Length
422
+ 0x03, // Instruction: WRITE_DATA
423
+ config.protocol.homingOffsetAddress, // Homing_Offset address
424
+ encodedOffset & 0xff, // Data_L (low byte)
425
+ (encodedOffset >> 8) & 0xff, // Data_H (high byte)
426
+ 0x00, // Checksum (will calculate)
427
+ ]);
428
+
429
+ // Calculate checksum
430
+ const checksum =
431
+ ~(
432
+ motorId +
433
+ 0x05 +
434
+ 0x03 +
435
+ config.protocol.homingOffsetAddress +
436
+ (encodedOffset & 0xff) +
437
+ ((encodedOffset >> 8) & 0xff)
438
+ ) & 0xff;
439
+ packet[8] = checksum;
440
+
441
+ if (!config.port || !config.port.isOpen) {
442
+ throw new Error("Serial port not open");
443
+ }
444
+
445
+ // Send packet
446
+ await new Promise<void>((resolve, reject) => {
447
+ config.port.write(packet, (error) => {
448
+ if (error) {
449
+ reject(
450
+ new Error(
451
+ `Failed to write homing offset for ${motorName}: ${error.message}`
452
+ )
453
+ );
454
+ } else {
455
+ resolve();
456
+ }
457
+ });
458
+ });
459
+
460
+ // Wait for response (silent unless error)
461
+ try {
462
+ await readData(config.port, 200);
463
+ } catch (error) {
464
+ // Silent - response not required for successful operation
465
+ }
466
+ } catch (error) {
467
+ throw new Error(
468
+ `Failed to write homing offset for ${motorName}: ${
469
+ error instanceof Error ? error.message : error
470
+ }`
471
+ );
472
+ }
473
+
474
+ // Small delay between motor writes
475
+ await new Promise((resolve) => setTimeout(resolve, 20));
476
+ }
477
+ }
478
+
479
  /**
480
  * Record ranges of motion with live updating table
481
  * Mirrors Python bus.record_ranges_of_motion()
 
484
  rangeMins: { [motor: string]: number };
485
  rangeMaxes: { [motor: string]: number };
486
  }> {
 
487
  console.log(
488
  "Move all joints sequentially through their entire ranges of motion."
489
  );
 
494
  const rangeMins: { [motor: string]: number } = {};
495
  const rangeMaxes: { [motor: string]: number } = {};
496
 
497
+ // Read actual current positions (matching Python exactly)
498
+ // Python does: start_positions = self.sync_read("Present_Position", motors, normalize=False)
499
+ // mins = start_positions.copy(); maxes = start_positions.copy()
500
+ const startPositions = await readMotorPositions(config);
501
+
502
  for (let i = 0; i < config.motorNames.length; i++) {
503
  const motorName = config.motorNames[i];
504
+ const startPosition = startPositions[i];
505
+ rangeMins[motorName] = startPosition; // Use actual position, not hardcoded 2047
506
+ rangeMaxes[motorName] = startPosition; // Use actual position, not hardcoded 2047
507
  }
508
 
509
  let recording = true;
 
520
  rl.close();
521
  });
522
 
 
 
 
523
  // Continuous recording loop with live updates - THE LIVE UPDATING TABLE!
524
  while (recording) {
525
  try {
 
542
  // Show real-time feedback every 3 reads for faster updates - LIVE TABLE UPDATE
543
  if (readCount % 3 === 0) {
544
  // Build the live table content
545
+ let liveTable = `Readings: ${readCount}\n\n`;
 
546
  liveTable += "Motor Name Current Min Max Range\n";
547
  liveTable += "─".repeat(55) + "\n";
548
 
 
620
  });
621
  });
622
  }
623
+
624
+ /**
625
+ * Write hardware position limits to motors
626
+ * Mirrors Python lerobot write_calibration() behavior where it writes:
627
+ * - Min_Position_Limit register with calibration.range_min
628
+ * - Max_Position_Limit register with calibration.range_max
629
+ * This physically constrains the motors to the calibrated ranges
630
+ */
631
+ async function writeHardwarePositionLimits(
632
+ config: SO100CalibrationConfig,
633
+ rangeMins: { [motor: string]: number },
634
+ rangeMaxes: { [motor: string]: number }
635
+ ): Promise<void> {
636
+ for (let i = 0; i < config.motorIds.length; i++) {
637
+ const motorId = config.motorIds[i];
638
+ const motorName = config.motorNames[i];
639
+ const minLimit = rangeMins[motorName];
640
+ const maxLimit = rangeMaxes[motorName];
641
+
642
+ try {
643
+ // Write Min_Position_Limit register
644
+ await writeMotorRegister(
645
+ config,
646
+ motorId,
647
+ config.protocol.minPositionLimitAddress,
648
+ minLimit,
649
+ `Min_Position_Limit for ${motorName}`
650
+ );
651
+
652
+ // Small delay between writes
653
+ await new Promise((resolve) => setTimeout(resolve, 20));
654
+
655
+ // Write Max_Position_Limit register
656
+ await writeMotorRegister(
657
+ config,
658
+ motorId,
659
+ config.protocol.maxPositionLimitAddress,
660
+ maxLimit,
661
+ `Max_Position_Limit for ${motorName}`
662
+ );
663
+
664
+ // Small delay between motors
665
+ await new Promise((resolve) => setTimeout(resolve, 20));
666
+ } catch (error) {
667
+ throw new Error(
668
+ `Failed to write position limits for ${motorName}: ${
669
+ error instanceof Error ? error.message : error
670
+ }`
671
+ );
672
+ }
673
+ }
674
+ }
675
+
676
+ /**
677
+ * Generic function to write a 2-byte value to a motor register
678
+ * Used for both Min_Position_Limit and Max_Position_Limit
679
+ */
680
+ async function writeMotorRegister(
681
+ config: SO100CalibrationConfig,
682
+ motorId: number,
683
+ registerAddress: number,
684
+ value: number,
685
+ description: string
686
+ ): Promise<void> {
687
+ // Create Write Register packet
688
+ const packet = Buffer.from([
689
+ 0xff,
690
+ 0xff, // Header
691
+ motorId, // Servo ID
692
+ 0x05, // Length (Instruction + Address + Data + Checksum)
693
+ 0x03, // Instruction: WRITE_DATA
694
+ registerAddress, // Register address
695
+ value & 0xff, // Data_L (low byte)
696
+ (value >> 8) & 0xff, // Data_H (high byte)
697
+ 0x00, // Checksum (will calculate)
698
+ ]);
699
+
700
+ // Calculate checksum
701
+ const checksum =
702
+ ~(
703
+ motorId +
704
+ 0x05 +
705
+ 0x03 +
706
+ registerAddress +
707
+ (value & 0xff) +
708
+ ((value >> 8) & 0xff)
709
+ ) & 0xff;
710
+ packet[8] = checksum;
711
+
712
+ if (!config.port || !config.port.isOpen) {
713
+ throw new Error("Serial port not open");
714
+ }
715
+
716
+ // Send packet
717
+ await new Promise<void>((resolve, reject) => {
718
+ config.port.write(packet, (error) => {
719
+ if (error) {
720
+ reject(new Error(`Failed to write ${description}: ${error.message}`));
721
+ } else {
722
+ resolve();
723
+ }
724
+ });
725
+ });
726
+
727
+ // Wait for response (silent unless error)
728
+ try {
729
+ await readData(config.port, 200);
730
+ } catch (error) {
731
+ // Silent - response not required for successful operation
732
+ }
733
+ }
src/lerobot/node/common/so100_config.ts CHANGED
@@ -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
  }
 
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
  }