File size: 19,446 Bytes
130ae50
 
 
 
 
 
ba80dbe
130ae50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ba80dbe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130ae50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ba80dbe
 
 
 
 
 
 
 
130ae50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
/**
 * SO-100 Follower Robot implementation for Node.js
 * Mirrors Python lerobot/common/robots/so100_follower/so100_follower.py
 */

import { Robot } from "./robot.js";
import type { RobotConfig } from "../types/robot-config.js";
import * as readline from "readline";

export class SO100Follower extends Robot {
  constructor(config: RobotConfig) {
    super(config);

    // Validate that this is an SO-100 follower config
    if (config.type !== "so100_follower") {
      throw new Error(
        `Invalid robot type: ${config.type}. Expected: so100_follower`
      );
    }
  }

  /**
   * Calibrate the SO-100 follower robot
   * NOTE: Calibration logic has been moved to shared/common/calibration.ts
   * This method is kept for backward compatibility but delegates to the main calibrate.ts
   */
  async calibrate(): Promise<void> {
    throw new Error(
      "Direct device calibration is deprecated. Use the main calibrate.ts orchestrator instead."
    );
  }

  /**
   * Initialize robot communication
   * For now, just test basic serial connectivity
   */
  private async initializeRobot(): Promise<void> {
    console.log("Initializing robot communication...");

    try {
      // For SO-100, we need to implement Feetech servo protocol
      // For now, just test that we can send/receive data
      console.log("Testing serial port connectivity...");

      // Try to ping servo ID 1 (shoulder_pan motor)
      // This is a very basic test - real implementation needs proper Feetech protocol
      const pingPacket = Buffer.from([0xff, 0xff, 0x01, 0x02, 0x01, 0xfb]); // Basic ping packet

      if (!this.port || !this.port.isOpen) {
        throw new Error("Serial port not open");
      }

      // Send ping packet
      await new Promise<void>((resolve, reject) => {
        this.port!.write(pingPacket, (error) => {
          if (error) {
            reject(new Error(`Failed to send ping: ${error.message}`));
          } else {
            resolve();
          }
        });
      });

      console.log("Ping packet sent successfully");

      // Try to read response with shorter timeout
      try {
        const response = await this.readData(1000); // 1 second timeout
        console.log(`Response received: ${response.length} bytes`);
      } catch (error) {
        console.log("No response received (expected for basic test)");
      }
    } catch (error) {
      throw new Error(
        `Serial communication test failed: ${
          error instanceof Error ? error.message : error
        }`
      );
    }

    console.log("Robot communication test completed.");
  }

  /**
   * Read current motor positions as a record with motor names
   * For teleoperation use
   */
  async getMotorPositions(): Promise<Record<string, number>> {
    const positions = await this.readMotorPositions();
    const motorNames = [
      "shoulder_pan",
      "shoulder_lift",
      "elbow_flex",
      "wrist_flex",
      "wrist_roll",
      "gripper",
    ];

    const result: Record<string, number> = {};
    for (let i = 0; i < motorNames.length; i++) {
      result[motorNames[i]] = positions[i];
    }
    return result;
  }

  /**
   * Get calibration data for teleoperation
   * Returns position limits and offsets from calibration file
   */
  getCalibrationLimits(): Record<string, { min: number; max: number }> {
    if (!this.isCalibrated || !this.calibration) {
      console.warn("No calibration data available, using default limits");
      // Default STS3215 limits as fallback
      return {
        shoulder_pan: { min: 985, max: 3085 },
        shoulder_lift: { min: 1200, max: 2800 },
        elbow_flex: { min: 1000, max: 3000 },
        wrist_flex: { min: 1100, max: 2900 },
        wrist_roll: { min: 0, max: 4095 }, // Full rotation motor
        gripper: { min: 1800, max: 2300 },
      };
    }

    // Extract limits from calibration data (matches Python format)
    const limits: Record<string, { min: number; max: number }> = {};
    for (const [motorName, calibData] of Object.entries(this.calibration)) {
      if (
        calibData &&
        typeof calibData === "object" &&
        "range_min" in calibData &&
        "range_max" in calibData
      ) {
        limits[motorName] = {
          min: Number(calibData.range_min),
          max: Number(calibData.range_max),
        };
      }
    }

    return limits;
  }

  /**
   * Set motor positions from a record with motor names
   * For teleoperation use
   */
  async setMotorPositions(positions: Record<string, number>): Promise<void> {
    const motorNames = [
      "shoulder_pan",
      "shoulder_lift",
      "elbow_flex",
      "wrist_flex",
      "wrist_roll",
      "gripper",
    ];
    const motorIds = [1, 2, 3, 4, 5, 6]; // SO-100 has servo IDs 1-6

    for (let i = 0; i < motorNames.length; i++) {
      const motorName = motorNames[i];
      const motorId = motorIds[i];
      const position = positions[motorName];

      if (position !== undefined) {
        await this.writeMotorPosition(motorId, position);
      }
    }
  }

  /**
   * Write position to a single motor
   * Implements STS3215 WRITE_DATA command for position control
   */
  private async writeMotorPosition(
    motorId: number,
    position: number
  ): Promise<void> {
    if (!this.port || !this.port.isOpen) {
      throw new Error("Serial port not open");
    }

    // Clamp position to valid range
    const clampedPosition = Math.max(0, Math.min(4095, Math.round(position)));

    // Create STS3215 Write Position packet
    // Format: [0xFF, 0xFF, ID, Length, Instruction, Address, Data_L, Data_H, Checksum]
    // Goal_Position address for STS3215 is 42 (0x2A), length 2 bytes
    const packet = Buffer.from([
      0xff,
      0xff, // Header
      motorId, // Servo ID
      0x05, // Length (Instruction + Address + Data_L + Data_H + Checksum)
      0x03, // Instruction: WRITE_DATA
      0x2a, // Address: Goal_Position (42)
      clampedPosition & 0xff, // Data_L (low byte)
      (clampedPosition >> 8) & 0xff, // Data_H (high byte)
      0x00, // Checksum (will calculate)
    ]);

    // Calculate checksum: ~(ID + Length + Instruction + Address + Data_L + Data_H) & 0xFF
    const checksum =
      ~(
        motorId +
        0x05 +
        0x03 +
        0x2a +
        (clampedPosition & 0xff) +
        ((clampedPosition >> 8) & 0xff)
      ) & 0xff;
    packet[8] = checksum;

    // Send write position packet
    await new Promise<void>((resolve, reject) => {
      this.port!.write(packet, (error) => {
        if (error) {
          reject(new Error(`Failed to send write packet: ${error.message}`));
        } else {
          resolve();
        }
      });
    });

    // Small delay to allow servo to process command
    await new Promise((resolve) => setTimeout(resolve, 1));
  }

  /**
   * Read current motor positions
   * Implements basic STS3215 servo protocol to read actual positions
   */
  private async readMotorPositions(): Promise<number[]> {
    console.log("Reading motor positions...");

    const motorPositions: number[] = [];
    const motorIds = [1, 2, 3, 4, 5, 6]; // SO-100 has servo IDs 1-6
    const motorNames = [
      "shoulder_pan",
      "shoulder_lift",
      "elbow_flex",
      "wrist_flex",
      "wrist_roll",
      "gripper",
    ];

    // Try to read position from each servo using STS3215 protocol
    for (let i = 0; i < motorIds.length; i++) {
      const motorId = motorIds[i];
      const motorName = motorNames[i];

      try {
        console.log(`  Reading ${motorName} (ID ${motorId})...`);

        // Create STS3215 Read Position packet
        // Format: [0xFF, 0xFF, ID, Length, Instruction, Address, DataLength, Checksum]
        // Present_Position address for STS3215 is 56 (0x38), length 2 bytes
        const packet = Buffer.from([
          0xff,
          0xff, // Header
          motorId, // Servo ID
          0x04, // Length (Instruction + Address + DataLength + Checksum)
          0x02, // Instruction: READ_DATA
          0x38, // Address: Present_Position (56)
          0x02, // Data Length: 2 bytes
          0x00, // Checksum (will calculate)
        ]);

        // Calculate checksum: ~(ID + Length + Instruction + Address + DataLength) & 0xFF
        const checksum = ~(motorId + 0x04 + 0x02 + 0x38 + 0x02) & 0xff;
        packet[7] = checksum;

        if (!this.port || !this.port.isOpen) {
          throw new Error("Serial port not open");
        }

        // Send read position packet
        await new Promise<void>((resolve, reject) => {
          this.port!.write(packet, (error) => {
            if (error) {
              reject(new Error(`Failed to send read packet: ${error.message}`));
            } else {
              resolve();
            }
          });
        });

        // Try to read response (timeout after 500ms)
        try {
          const response = await this.readData(500);

          if (response.length >= 7) {
            // Parse response: [0xFF, 0xFF, ID, Length, Error, Data_L, Data_H, Checksum]
            const id = response[2];
            const error = response[4];

            if (id === motorId && error === 0) {
              // Extract 16-bit position from Data_L and Data_H
              const position = response[5] | (response[6] << 8);
              motorPositions.push(position);

              // Show calibrated range if available
              const calibratedLimits = this.getCalibrationLimits();
              const limits = calibratedLimits[motorName];
              const rangeText = limits
                ? `(${limits.min}-${limits.max} calibrated)`
                : `(0-4095 raw)`;
              console.log(`    ${motorName}: ${position} ${rangeText}`);
            } else {
              console.warn(
                `    ${motorName}: Error response (error code: ${error})`
              );
              motorPositions.push(2047); // Use center position as fallback
            }
          } else {
            console.warn(`    ${motorName}: Invalid response length`);
            motorPositions.push(2047); // Use center position as fallback
          }
        } catch (readError) {
          console.warn(
            `    ${motorName}: Read timeout - using fallback position`
          );
          motorPositions.push(2047); // Use center position as fallback
        }
      } catch (error) {
        console.warn(
          `    ${motorName}: Communication error - ${
            error instanceof Error ? error.message : error
          }`
        );
        motorPositions.push(2047); // Use center position as fallback
      }

      // Small delay between servo reads
      await new Promise((resolve) => setTimeout(resolve, 10));
    }

    console.log(`Motor positions: [${motorPositions.join(", ")}]`);
    return motorPositions;
  }

  /**
   * Set motor limits and safety parameters
   * TODO: Implement proper Feetech servo protocol
   */
  private async setMotorLimits(): Promise<any> {
    console.log("Setting motor limits...");

    // Set default limits for SO-100 (based on Python implementation)
    const limits = {
      position_min: [-180, -90, -90, -90, -90, -90],
      position_max: [180, 90, 90, 90, 90, 90],
      velocity_max: [100, 100, 100, 100, 100, 100],
      torque_max: [50, 50, 50, 50, 25, 25],
    };

    // For now, just return the limits without sending to robot
    // Real implementation needs Feetech servo protocol to set limits
    console.log("Motor limits configured (mock).");
    return limits;
  }

  /**
   * Interactive calibration process - matches Python lerobot calibration flow
   * Implements real calibration with user interaction
   */
  private async calibrateMotors(): Promise<any> {
    console.log("\n=== INTERACTIVE CALIBRATION ===");
    console.log("Starting SO-100 follower arm calibration...");

    // Step 1: Move to middle position and record homing offsets
    console.log("\nπŸ“ STEP 1: Set Homing Position");
    await this.promptUser(
      "Move the SO-100 to the MIDDLE of its range of motion and press ENTER..."
    );

    const homingOffsets = await this.setHomingOffsets();

    // Step 2: Record ranges of motion
    console.log("\nπŸ“ STEP 2: Record Joint Ranges");
    const { rangeMins, rangeMaxes } = await this.recordRangesOfMotion();

    // Step 3: Set special range for wrist_roll (full turn motor)
    console.log("\nπŸ”„ STEP 3: Configure Full-Turn Motor");
    console.log("Setting wrist_roll as full-turn motor (0-4095 range)");
    rangeMins["wrist_roll"] = 0;
    rangeMaxes["wrist_roll"] = 4095;

    // Step 4: Compile calibration results
    const motorNames = [
      "shoulder_pan",
      "shoulder_lift",
      "elbow_flex",
      "wrist_flex",
      "wrist_roll",
      "gripper",
    ];
    const results = [];

    for (let i = 0; i < motorNames.length; i++) {
      const motorId = i + 1; // Servo IDs are 1-6
      const motorName = motorNames[i];

      results.push({
        motor: motorId,
        name: motorName,
        status: "success",
        homing_offset: homingOffsets[motorName],
        range_min: rangeMins[motorName],
        range_max: rangeMaxes[motorName],
        range_size: rangeMaxes[motorName] - rangeMins[motorName],
      });

      console.log(
        `βœ… ${motorName} calibrated: range ${rangeMins[motorName]} to ${rangeMaxes[motorName]} (offset: ${homingOffsets[motorName]})`
      );
    }

    console.log("\nπŸŽ‰ Interactive calibration completed!");
    return results;
  }

  /**
   * Verify calibration was successful
   * TODO: Implement proper verification with Feetech servo protocol
   */
  private async verifyCalibration(): Promise<void> {
    console.log("Verifying calibration...");

    // For now, just mock successful verification
    // Real implementation should check:
    // 1. All motors respond to ping
    // 2. Position limits are set correctly
    // 3. Homing offsets are applied
    // 4. Motors can move to test positions

    console.log("Calibration verification passed (mock).");
  }

  /**
   * Prompt user for input (like Python's input() function)
   */
  private async promptUser(message: string): Promise<string> {
    const rl = readline.createInterface({
      input: process.stdin,
      output: process.stdout,
    });

    return new Promise((resolve) => {
      rl.question(message, (answer) => {
        rl.close();
        resolve(answer);
      });
    });
  }

  /**
   * Record homing offsets (current positions as center)
   * Mirrors Python bus.set_half_turn_homings()
   */
  private async setHomingOffsets(): Promise<{ [motor: string]: number }> {
    console.log("Recording current positions as homing offsets...");

    const currentPositions = await this.readMotorPositions();
    const motorNames = [
      "shoulder_pan",
      "shoulder_lift",
      "elbow_flex",
      "wrist_flex",
      "wrist_roll",
      "gripper",
    ];
    const homingOffsets: { [motor: string]: number } = {};

    for (let i = 0; i < motorNames.length; i++) {
      const motorName = motorNames[i];
      const position = currentPositions[i];
      // Calculate homing offset (half turn offset from current position)
      const maxRes = 4095; // STS3215 resolution
      homingOffsets[motorName] = position - Math.floor(maxRes / 2);
      console.log(
        `  ${motorName}: offset ${homingOffsets[motorName]} (current pos: ${position})`
      );
    }

    return homingOffsets;
  }

  /**
   * Record ranges of motion by continuously reading positions
   * Mirrors Python bus.record_ranges_of_motion()
   */
  private async recordRangesOfMotion(): Promise<{
    rangeMins: { [motor: string]: number };
    rangeMaxes: { [motor: string]: number };
  }> {
    console.log("\n=== RECORDING RANGES OF MOTION ===");
    console.log(
      "Move all joints sequentially through their entire ranges of motion."
    );
    console.log(
      "Positions will be recorded continuously. Press ENTER to stop...\n"
    );

    const motorNames = [
      "shoulder_pan",
      "shoulder_lift",
      "elbow_flex",
      "wrist_flex",
      "wrist_roll",
      "gripper",
    ];
    const rangeMins: { [motor: string]: number } = {};
    const rangeMaxes: { [motor: string]: number } = {};

    // Initialize with current positions
    const initialPositions = await this.readMotorPositions();
    for (let i = 0; i < motorNames.length; i++) {
      const motorName = motorNames[i];
      const position = initialPositions[i];
      rangeMins[motorName] = position;
      rangeMaxes[motorName] = position;
    }

    let recording = true;
    let readCount = 0;

    // Set up readline to detect Enter key
    const rl = readline.createInterface({
      input: process.stdin,
      output: process.stdout,
    });

    rl.on("line", () => {
      recording = false;
      rl.close();
    });

    console.log("Recording started... (move the robot joints now)");

    // Continuous recording loop
    while (recording) {
      try {
        const positions = await this.readMotorPositions();
        readCount++;

        // Update min/max ranges
        for (let i = 0; i < motorNames.length; i++) {
          const motorName = motorNames[i];
          const position = positions[i];

          if (position < rangeMins[motorName]) {
            rangeMins[motorName] = position;
          }
          if (position > rangeMaxes[motorName]) {
            rangeMaxes[motorName] = position;
          }
        }

        // Show real-time feedback every 10 reads
        if (readCount % 10 === 0) {
          console.clear(); // Clear screen for live update
          console.log("=== LIVE POSITION RECORDING ===");
          console.log(`Readings: ${readCount} | Press ENTER to stop\n`);

          console.log("Motor Name       Current    Min      Max      Range");
          console.log("─".repeat(55));

          for (let i = 0; i < motorNames.length; i++) {
            const motorName = motorNames[i];
            const current = positions[i];
            const min = rangeMins[motorName];
            const max = rangeMaxes[motorName];
            const range = max - min;

            console.log(
              `${motorName.padEnd(15)} ${current.toString().padStart(6)} ${min
                .toString()
                .padStart(6)} ${max.toString().padStart(6)} ${range
                .toString()
                .padStart(8)}`
            );
          }
          console.log("\nMove joints through their full range...");
        }

        // Small delay to avoid overwhelming the serial port
        await new Promise((resolve) => setTimeout(resolve, 50));
      } catch (error) {
        console.warn(
          `Read error: ${error instanceof Error ? error.message : error}`
        );
        await new Promise((resolve) => setTimeout(resolve, 100));
      }
    }

    console.log(`\nRecording stopped after ${readCount} readings.`);
    console.log("\nFinal ranges recorded:");

    for (const motorName of motorNames) {
      const min = rangeMins[motorName];
      const max = rangeMaxes[motorName];
      const range = max - min;
      console.log(`  ${motorName}: ${min} to ${max} (range: ${range})`);
    }

    return { rangeMins, rangeMaxes };
  }
}

/**
 * Factory function to create SO-100 follower robot
 * Mirrors Python's make_robot_from_config pattern
 */
export function createSO100Follower(config: RobotConfig): SO100Follower {
  return new SO100Follower(config);
}