NERDDISCO commited on
Commit
130ae50
·
1 Parent(s): b664dbe

recover: restore deleted src/lerobot/node/ folder from ec936d5

Browse files
examples/demo/App.tsx ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import { Home } from "./pages/Home";
3
+ import { ErrorBoundary } from "./components/ErrorBoundary";
4
+ import type { RobotConnection } from "@lerobot/web";
5
+
6
+ export function App() {
7
+ const [connectedRobots, setConnectedRobots] = useState<RobotConnection[]>([]);
8
+
9
+ return (
10
+ <ErrorBoundary>
11
+ <div className="min-h-screen bg-background">
12
+ <Home
13
+ connectedRobots={connectedRobots}
14
+ onConnectedRobotsChange={setConnectedRobots}
15
+ />
16
+ </div>
17
+ </ErrorBoundary>
18
+ );
19
+ }
examples/demo/components/CalibrationModal.tsx ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ Dialog,
3
+ DialogContent,
4
+ DialogDescription,
5
+ DialogFooter,
6
+ DialogHeader,
7
+ DialogTitle,
8
+ } from "./ui/dialog";
9
+ import { Button } from "./ui/button";
10
+
11
+ interface CalibrationModalProps {
12
+ open: boolean;
13
+ onOpenChange: (open: boolean) => void;
14
+ deviceType: string;
15
+ onContinue: () => void;
16
+ }
17
+
18
+ export function CalibrationModal({
19
+ open,
20
+ onOpenChange,
21
+ deviceType,
22
+ onContinue,
23
+ }: CalibrationModalProps) {
24
+ return (
25
+ <Dialog open={open} onOpenChange={onOpenChange}>
26
+ <DialogContent className="sm:max-w-md">
27
+ <DialogHeader>
28
+ <DialogTitle>📍 Set Homing Position</DialogTitle>
29
+ <DialogDescription className="text-base py-4">
30
+ Move the SO-100 {deviceType} to the <strong>MIDDLE</strong> of its
31
+ range of motion and click OK when ready.
32
+ <br />
33
+ <br />
34
+ The calibration will then automatically:
35
+ <br />• Record homing offsets
36
+ <br />• Record joint ranges (manual - you control when to stop)
37
+ <br />• Save configuration file
38
+ </DialogDescription>
39
+ </DialogHeader>
40
+
41
+ <DialogFooter>
42
+ <Button onClick={onContinue} className="w-full">
43
+ OK - Start Calibration
44
+ </Button>
45
+ </DialogFooter>
46
+ </DialogContent>
47
+ </Dialog>
48
+ );
49
+ }
examples/demo/components/CalibrationPanel.tsx ADDED
@@ -0,0 +1,419 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useCallback, useMemo } from "react";
2
+ import { Button } from "./ui/button.js";
3
+ import {
4
+ Card,
5
+ CardContent,
6
+ CardDescription,
7
+ CardHeader,
8
+ CardTitle,
9
+ } from "./ui/card.js";
10
+ import { Badge } from "./ui/badge.js";
11
+ import {
12
+ calibrate,
13
+ type WebCalibrationResults,
14
+ type LiveCalibrationData,
15
+ type CalibrationProcess,
16
+ } from "@lerobot/web";
17
+ import { releaseMotors } from "@lerobot/web";
18
+ import { WebSerialPortWrapper } from "@lerobot/web";
19
+ import { createSO100Config } from "@lerobot/web";
20
+ import { CalibrationModal } from "./CalibrationModal.js";
21
+ import type { RobotConnection } from "@lerobot/web";
22
+
23
+ interface CalibrationPanelProps {
24
+ robot: RobotConnection;
25
+ onFinish: () => void;
26
+ }
27
+
28
+ export function CalibrationPanel({ robot, onFinish }: CalibrationPanelProps) {
29
+ // Simple state management
30
+ const [isCalibrating, setIsCalibrating] = useState(false);
31
+ const [calibrationResult, setCalibrationResult] =
32
+ useState<WebCalibrationResults | null>(null);
33
+ const [status, setStatus] = useState<string>("Ready to calibrate");
34
+ const [modalOpen, setModalOpen] = useState(false);
35
+ const [calibrationProcess, setCalibrationProcess] =
36
+ useState<CalibrationProcess | null>(null);
37
+ const [motorData, setMotorData] = useState<LiveCalibrationData>({});
38
+ const [isPreparing, setIsPreparing] = useState(false);
39
+
40
+ // Motor names for display
41
+ const motorNames = useMemo(
42
+ () => [
43
+ "shoulder_pan",
44
+ "shoulder_lift",
45
+ "elbow_flex",
46
+ "wrist_flex",
47
+ "wrist_roll",
48
+ "gripper",
49
+ ],
50
+ []
51
+ );
52
+
53
+ // Initialize motor data
54
+ const initializeMotorData = useCallback(() => {
55
+ const initialData: LiveCalibrationData = {};
56
+ motorNames.forEach((name) => {
57
+ initialData[name] = {
58
+ current: 2047,
59
+ min: 2047,
60
+ max: 2047,
61
+ range: 0,
62
+ };
63
+ });
64
+ setMotorData(initialData);
65
+ }, [motorNames]);
66
+
67
+ // Release motor torque for better UX - allows immediate joint movement
68
+ const releaseMotorTorque = useCallback(async () => {
69
+ if (!robot.port || !robot.robotType) {
70
+ return;
71
+ }
72
+
73
+ try {
74
+ setIsPreparing(true);
75
+ setStatus("🔓 Releasing motor torque - joints can now be moved freely");
76
+
77
+ // Create port wrapper and config to get motor IDs
78
+ const port = new WebSerialPortWrapper(robot.port);
79
+ await port.initialize();
80
+ const config = createSO100Config(robot.robotType);
81
+
82
+ // Release motors so they can be moved freely by hand
83
+ await releaseMotors(port, config.motorIds);
84
+
85
+ setStatus("✅ Joints are now free to move - set your homing position");
86
+ } catch (error) {
87
+ console.warn("Failed to release motor torque:", error);
88
+ setStatus("⚠️ Could not release motor torque - try moving joints gently");
89
+ } finally {
90
+ setIsPreparing(false);
91
+ }
92
+ }, [robot]);
93
+
94
+ // Start calibration using new API
95
+ const handleContinueCalibration = useCallback(async () => {
96
+ setModalOpen(false);
97
+
98
+ if (!robot.port || !robot.robotType) {
99
+ return;
100
+ }
101
+
102
+ try {
103
+ setStatus("🤖 Starting calibration process...");
104
+ setIsCalibrating(true);
105
+ initializeMotorData();
106
+
107
+ // Use the simple calibrate API - just pass the robot connection
108
+ const process = await calibrate(robot, {
109
+ onLiveUpdate: (data) => {
110
+ setMotorData(data);
111
+ setStatus(
112
+ "📏 Recording joint ranges - move all joints through their full range"
113
+ );
114
+ },
115
+ onProgress: (message) => {
116
+ setStatus(message);
117
+ },
118
+ });
119
+
120
+ setCalibrationProcess(process);
121
+
122
+ // Add Enter key listener for stopping (matching Node.js UX)
123
+ const handleKeyPress = (event: KeyboardEvent) => {
124
+ if (event.key === "Enter") {
125
+ process.stop();
126
+ }
127
+ };
128
+ document.addEventListener("keydown", handleKeyPress);
129
+
130
+ try {
131
+ // Wait for calibration to complete
132
+ const result = await process.result;
133
+ setCalibrationResult(result);
134
+
135
+ // App-level concern: Save results to storage
136
+ const serialNumber =
137
+ robot.serialNumber || robot.usbMetadata?.serialNumber || "unknown";
138
+ await saveCalibrationResults(
139
+ result,
140
+ robot.robotType,
141
+ robot.robotId || `${robot.robotType}_1`,
142
+ serialNumber
143
+ );
144
+
145
+ setStatus(
146
+ "✅ Calibration completed successfully! Configuration saved."
147
+ );
148
+ } finally {
149
+ document.removeEventListener("keydown", handleKeyPress);
150
+ setCalibrationProcess(null);
151
+ setIsCalibrating(false);
152
+ }
153
+ } catch (error) {
154
+ console.error("❌ Calibration failed:", error);
155
+ setStatus(
156
+ `❌ Calibration failed: ${
157
+ error instanceof Error ? error.message : error
158
+ }`
159
+ );
160
+ setIsCalibrating(false);
161
+ setCalibrationProcess(null);
162
+ }
163
+ }, [robot, initializeMotorData]);
164
+
165
+ // Stop calibration recording
166
+ const handleStopRecording = useCallback(() => {
167
+ if (calibrationProcess) {
168
+ calibrationProcess.stop();
169
+ }
170
+ }, [calibrationProcess]);
171
+
172
+ // App-level concern: Save calibration results
173
+ const saveCalibrationResults = async (
174
+ results: WebCalibrationResults,
175
+ robotType: string,
176
+ robotId: string,
177
+ serialNumber: string
178
+ ) => {
179
+ try {
180
+ // Save to unified storage (app-level functionality)
181
+ const { saveCalibrationData } = await import("../lib/unified-storage.js");
182
+
183
+ const fullCalibrationData = {
184
+ ...results,
185
+ device_type: robotType,
186
+ device_id: robotId,
187
+ calibrated_at: new Date().toISOString(),
188
+ platform: "web",
189
+ api: "Web Serial API",
190
+ };
191
+
192
+ const metadata = {
193
+ timestamp: new Date().toISOString(),
194
+ readCount: Object.keys(motorData).length > 0 ? 100 : 0, // Estimate
195
+ };
196
+
197
+ saveCalibrationData(serialNumber, fullCalibrationData, metadata);
198
+ } catch (error) {
199
+ console.warn("Failed to save calibration results:", error);
200
+ }
201
+ };
202
+
203
+ // App-level concern: JSON export functionality
204
+ const downloadConfigJSON = useCallback(() => {
205
+ if (!calibrationResult) return;
206
+
207
+ const jsonString = JSON.stringify(calibrationResult, null, 2);
208
+ const blob = new Blob([jsonString], { type: "application/json" });
209
+ const url = URL.createObjectURL(blob);
210
+
211
+ const link = document.createElement("a");
212
+ link.href = url;
213
+ link.download = `${robot.robotId || robot.robotType}_calibration.json`;
214
+ document.body.appendChild(link);
215
+ link.click();
216
+ document.body.removeChild(link);
217
+ URL.revokeObjectURL(url);
218
+ }, [calibrationResult, robot.robotId, robot.robotType]);
219
+
220
+ return (
221
+ <div className="space-y-4">
222
+ {/* Calibration Status Card */}
223
+ <Card>
224
+ <CardHeader>
225
+ <div className="flex items-center justify-between">
226
+ <div>
227
+ <CardTitle className="text-lg">
228
+ 🛠️ Calibrating: {robot.robotId}
229
+ </CardTitle>
230
+ <CardDescription>
231
+ {robot.robotType?.replace("_", " ")} • {robot.name}
232
+ </CardDescription>
233
+ </div>
234
+ <Badge
235
+ variant={
236
+ isCalibrating
237
+ ? "default"
238
+ : calibrationResult
239
+ ? "default"
240
+ : "outline"
241
+ }
242
+ >
243
+ {isCalibrating
244
+ ? "Recording"
245
+ : calibrationResult
246
+ ? "Complete"
247
+ : "Ready"}
248
+ </Badge>
249
+ </div>
250
+ </CardHeader>
251
+ <CardContent>
252
+ <div className="space-y-4">
253
+ <div className="p-3 bg-blue-50 rounded-lg">
254
+ <p className="text-sm font-medium text-blue-900">Status:</p>
255
+ <p className="text-sm text-blue-800">{status}</p>
256
+ {isCalibrating && (
257
+ <p className="text-xs text-blue-600 mt-1">
258
+ Move joints through full range | Press "Finish Recording" or
259
+ Enter key when done
260
+ </p>
261
+ )}
262
+ </div>
263
+
264
+ <div className="flex gap-2">
265
+ {!isCalibrating && !calibrationResult && (
266
+ <Button
267
+ onClick={async () => {
268
+ // Release motor torque FIRST - so user can move joints immediately
269
+ await releaseMotorTorque();
270
+ // THEN open modal - user can now follow instructions right away
271
+ setModalOpen(true);
272
+ }}
273
+ disabled={isPreparing}
274
+ >
275
+ {isPreparing ? "Preparing..." : "Start Calibration"}
276
+ </Button>
277
+ )}
278
+
279
+ {isCalibrating && calibrationProcess && (
280
+ <Button onClick={handleStopRecording} variant="default">
281
+ Finish Recording
282
+ </Button>
283
+ )}
284
+
285
+ {calibrationResult && (
286
+ <>
287
+ <Button onClick={downloadConfigJSON} variant="outline">
288
+ Download Config JSON
289
+ </Button>
290
+ <Button onClick={onFinish}>Done</Button>
291
+ </>
292
+ )}
293
+ </div>
294
+ </div>
295
+ </CardContent>
296
+ </Card>
297
+
298
+ {/* Configuration JSON Display */}
299
+ {calibrationResult && (
300
+ <Card>
301
+ <CardHeader>
302
+ <CardTitle className="text-lg">
303
+ 🎯 Calibration Configuration
304
+ </CardTitle>
305
+ <CardDescription>
306
+ Copy this JSON or download it for your robot setup
307
+ </CardDescription>
308
+ </CardHeader>
309
+ <CardContent>
310
+ <div className="space-y-3">
311
+ <pre className="bg-gray-100 p-4 rounded-lg text-sm overflow-x-auto border">
312
+ <code>{JSON.stringify(calibrationResult, null, 2)}</code>
313
+ </pre>
314
+ <div className="flex gap-2">
315
+ <Button onClick={downloadConfigJSON} variant="outline">
316
+ 📄 Download JSON File
317
+ </Button>
318
+ <Button
319
+ onClick={() => {
320
+ navigator.clipboard.writeText(
321
+ JSON.stringify(calibrationResult, null, 2)
322
+ );
323
+ }}
324
+ variant="outline"
325
+ >
326
+ 📋 Copy to Clipboard
327
+ </Button>
328
+ </div>
329
+ </div>
330
+ </CardContent>
331
+ </Card>
332
+ )}
333
+
334
+ {/* Live Position Recording Table */}
335
+ <Card>
336
+ <CardHeader>
337
+ <CardTitle className="text-lg">Live Position Recording</CardTitle>
338
+ <CardDescription>
339
+ Real-time motor position feedback during calibration
340
+ </CardDescription>
341
+ </CardHeader>
342
+ <CardContent>
343
+ <div className="overflow-hidden rounded-lg border">
344
+ <table className="w-full font-mono text-sm">
345
+ <thead className="bg-gray-50">
346
+ <tr>
347
+ <th className="px-4 py-2 text-left font-medium text-gray-900">
348
+ Motor Name
349
+ </th>
350
+ <th className="px-4 py-2 text-right font-medium text-gray-900">
351
+ Current
352
+ </th>
353
+ <th className="px-4 py-2 text-right font-medium text-gray-900">
354
+ Min
355
+ </th>
356
+ <th className="px-4 py-2 text-right font-medium text-gray-900">
357
+ Max
358
+ </th>
359
+ <th className="px-4 py-2 text-right font-medium text-gray-900">
360
+ Range
361
+ </th>
362
+ </tr>
363
+ </thead>
364
+ <tbody className="divide-y divide-gray-200">
365
+ {motorNames.map((motorName) => {
366
+ const motor = motorData[motorName] || {
367
+ current: 2047,
368
+ min: 2047,
369
+ max: 2047,
370
+ range: 0,
371
+ };
372
+
373
+ return (
374
+ <tr key={motorName} className="hover:bg-gray-50">
375
+ <td className="px-4 py-2 font-medium flex items-center gap-2">
376
+ {motorName}
377
+ {motor.range > 100 && (
378
+ <span className="text-green-600 text-xs">✓</span>
379
+ )}
380
+ </td>
381
+ <td className="px-4 py-2 text-right">{motor.current}</td>
382
+ <td className="px-4 py-2 text-right">{motor.min}</td>
383
+ <td className="px-4 py-2 text-right">{motor.max}</td>
384
+ <td className="px-4 py-2 text-right font-medium">
385
+ <span
386
+ className={
387
+ motor.range > 100
388
+ ? "text-green-600"
389
+ : "text-gray-500"
390
+ }
391
+ >
392
+ {motor.range}
393
+ </span>
394
+ </td>
395
+ </tr>
396
+ );
397
+ })}
398
+ </tbody>
399
+ </table>
400
+ </div>
401
+
402
+ {isCalibrating && (
403
+ <div className="mt-3 text-center text-sm text-gray-600">
404
+ Move joints through their full range of motion...
405
+ </div>
406
+ )}
407
+ </CardContent>
408
+ </Card>
409
+
410
+ {/* Calibration Modal */}
411
+ <CalibrationModal
412
+ open={modalOpen}
413
+ onOpenChange={setModalOpen}
414
+ deviceType={robot.robotType || "robot"}
415
+ onContinue={handleContinueCalibration}
416
+ />
417
+ </div>
418
+ );
419
+ }
examples/demo/components/ErrorBoundary.tsx ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Component, type ErrorInfo, type ReactNode } from "react";
2
+ import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
3
+ import { Button } from "./ui/button";
4
+
5
+ interface Props {
6
+ children: ReactNode;
7
+ }
8
+
9
+ interface State {
10
+ hasError: boolean;
11
+ error?: Error;
12
+ }
13
+
14
+ export class ErrorBoundary extends Component<Props, State> {
15
+ constructor(props: Props) {
16
+ super(props);
17
+ this.state = { hasError: false };
18
+ }
19
+
20
+ static getDerivedStateFromError(error: Error): State {
21
+ return { hasError: true, error };
22
+ }
23
+
24
+ componentDidCatch(error: Error, errorInfo: ErrorInfo) {
25
+ console.error("ErrorBoundary caught an error:", error, errorInfo);
26
+ }
27
+
28
+ render() {
29
+ if (this.state.hasError) {
30
+ return (
31
+ <div className="min-h-screen flex items-center justify-center p-8">
32
+ <div className="max-w-md w-full">
33
+ <Alert variant="destructive">
34
+ <AlertTitle>Something went wrong</AlertTitle>
35
+ <AlertDescription>
36
+ The application encountered an error. Please try refreshing the
37
+ page or contact support if the problem persists.
38
+ </AlertDescription>
39
+ </Alert>
40
+ <div className="mt-4 flex gap-2">
41
+ <Button onClick={() => window.location.reload()}>
42
+ Refresh Page
43
+ </Button>
44
+ <Button
45
+ variant="outline"
46
+ onClick={() =>
47
+ this.setState({ hasError: false, error: undefined })
48
+ }
49
+ >
50
+ Try Again
51
+ </Button>
52
+ </div>
53
+ {process.env.NODE_ENV === "development" && this.state.error && (
54
+ <div className="mt-4 p-4 bg-gray-100 rounded-md text-xs">
55
+ <pre>{this.state.error.stack}</pre>
56
+ </div>
57
+ )}
58
+ </div>
59
+ </div>
60
+ );
61
+ }
62
+
63
+ return this.props.children;
64
+ }
65
+ }
examples/demo/components/PortManager.tsx ADDED
@@ -0,0 +1,1251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from "react";
2
+ import { Button } from "./ui/button";
3
+ import {
4
+ Card,
5
+ CardContent,
6
+ CardDescription,
7
+ CardHeader,
8
+ CardTitle,
9
+ } from "./ui/card";
10
+ import { Alert, AlertDescription } from "./ui/alert";
11
+ import { Badge } from "./ui/badge";
12
+ import {
13
+ Dialog,
14
+ DialogContent,
15
+ DialogDescription,
16
+ DialogFooter,
17
+ DialogHeader,
18
+ DialogTitle,
19
+ } from "./ui/dialog";
20
+ import { isWebSerialSupported } from "@lerobot/web";
21
+ import type { RobotConnection } from "@lerobot/web";
22
+
23
+ /**
24
+ * Type definitions for WebSerial API (missing from TypeScript)
25
+ */
26
+ interface SerialPortInfo {
27
+ usbVendorId?: number;
28
+ usbProductId?: number;
29
+ }
30
+
31
+ declare global {
32
+ interface SerialPort {
33
+ getInfo(): SerialPortInfo;
34
+ }
35
+ }
36
+
37
+ interface PortManagerProps {
38
+ connectedRobots: RobotConnection[];
39
+ onConnectedRobotsChange: (robots: RobotConnection[]) => void;
40
+ onCalibrate?: (
41
+ port: SerialPort,
42
+ robotType: "so100_follower" | "so100_leader",
43
+ robotId: string
44
+ ) => void;
45
+ onTeleoperate?: (robot: RobotConnection) => void;
46
+ }
47
+
48
+ export function PortManager({
49
+ connectedRobots,
50
+ onConnectedRobotsChange,
51
+ onCalibrate,
52
+ onTeleoperate,
53
+ }: PortManagerProps) {
54
+ const [isConnecting, setIsConnecting] = useState(false);
55
+ const [isFindingPorts, setIsFindingPorts] = useState(false);
56
+ const [findPortsLog, setFindPortsLog] = useState<string[]>([]);
57
+ const [error, setError] = useState<string | null>(null);
58
+ const [confirmDeleteDialog, setConfirmDeleteDialog] = useState<{
59
+ open: boolean;
60
+ robotIndex: number;
61
+ robotName: string;
62
+ serialNumber: string;
63
+ }>({
64
+ open: false,
65
+ robotIndex: -1,
66
+ robotName: "",
67
+ serialNumber: "",
68
+ });
69
+ // Load saved port data from localStorage on mount
70
+ useEffect(() => {
71
+ loadSavedPorts();
72
+ }, []);
73
+
74
+ // Note: Robot data is now automatically saved to unified storage when robot config is updated
75
+
76
+ const loadSavedPorts = async () => {
77
+ try {
78
+ const existingPorts = await navigator.serial.getPorts();
79
+ const restoredPorts: RobotConnection[] = [];
80
+
81
+ for (const port of existingPorts) {
82
+ // Get USB device metadata to determine serial number
83
+ let serialNumber = null;
84
+ let usbMetadata = null;
85
+
86
+ try {
87
+ // Get all USB devices and try to match with this serial port
88
+ const usbDevices = await navigator.usb.getDevices();
89
+ const portInfo = port.getInfo();
90
+
91
+ // Try to find matching USB device by vendor/product ID
92
+ const matchingDevice = usbDevices.find(
93
+ (device) =>
94
+ device.vendorId === portInfo.usbVendorId &&
95
+ device.productId === portInfo.usbProductId
96
+ );
97
+
98
+ if (matchingDevice) {
99
+ serialNumber =
100
+ matchingDevice.serialNumber ||
101
+ `${matchingDevice.vendorId}-${
102
+ matchingDevice.productId
103
+ }-${Date.now()}`;
104
+ usbMetadata = {
105
+ vendorId: `0x${matchingDevice.vendorId
106
+ .toString(16)
107
+ .padStart(4, "0")}`,
108
+ productId: `0x${matchingDevice.productId
109
+ .toString(16)
110
+ .padStart(4, "0")}`,
111
+ serialNumber: matchingDevice.serialNumber || "Generated ID",
112
+ manufacturerName: matchingDevice.manufacturerName || "Unknown",
113
+ productName: matchingDevice.productName || "Unknown",
114
+ usbVersionMajor: matchingDevice.usbVersionMajor,
115
+ usbVersionMinor: matchingDevice.usbVersionMinor,
116
+ deviceClass: matchingDevice.deviceClass,
117
+ deviceSubclass: matchingDevice.deviceSubclass,
118
+ deviceProtocol: matchingDevice.deviceProtocol,
119
+ };
120
+ console.log("✅ Restored USB metadata for port:", serialNumber);
121
+ }
122
+ } catch (usbError) {
123
+ console.log("⚠️ Could not restore USB metadata:", usbError);
124
+ // Generate fallback if no USB metadata available
125
+ serialNumber = `fallback-${Date.now()}-${Math.random()
126
+ .toString(36)
127
+ .substr(2, 9)}`;
128
+ }
129
+
130
+ // Load robot configuration from unified storage
131
+ let robotType: "so100_follower" | "so100_leader" | undefined;
132
+ let robotId: string | undefined;
133
+ let shouldAutoConnect = false;
134
+
135
+ if (serialNumber) {
136
+ try {
137
+ const { getUnifiedRobotData } = await import(
138
+ "../lib/unified-storage"
139
+ );
140
+ const unifiedData = getUnifiedRobotData(serialNumber);
141
+ if (unifiedData?.device_info) {
142
+ robotType = unifiedData.device_info.robotType;
143
+ robotId = unifiedData.device_info.robotId;
144
+ shouldAutoConnect = true;
145
+ console.log(
146
+ `📋 Loaded robot config from unified storage: ${robotType} (${robotId})`
147
+ );
148
+ }
149
+ } catch (error) {
150
+ console.warn("Failed to load unified robot data:", error);
151
+ }
152
+ }
153
+
154
+ // Auto-connect to configured robots
155
+ let isConnected = false;
156
+ try {
157
+ // Check if already open
158
+ if (port.readable !== null && port.writable !== null) {
159
+ isConnected = true;
160
+ console.log("Port already open, reusing connection");
161
+ } else if (shouldAutoConnect && robotType && robotId) {
162
+ // Auto-open robots that have saved configuration
163
+ console.log(
164
+ `Auto-connecting to saved robot: ${robotType} (${robotId})`
165
+ );
166
+ await port.open({ baudRate: 1000000 });
167
+ isConnected = true;
168
+ } else {
169
+ console.log(
170
+ "Port found but no saved robot configuration, skipping auto-connect"
171
+ );
172
+ isConnected = false;
173
+ }
174
+ } catch (error) {
175
+ console.log("Could not auto-connect to robot:", error);
176
+ isConnected = false;
177
+ }
178
+
179
+ restoredPorts.push({
180
+ port,
181
+ name: getPortDisplayName(port),
182
+ isConnected,
183
+ robotType,
184
+ robotId,
185
+ serialNumber: serialNumber!,
186
+ usbMetadata: usbMetadata || undefined,
187
+ });
188
+ }
189
+
190
+ onConnectedRobotsChange(restoredPorts);
191
+ } catch (error) {
192
+ console.error("Failed to load saved ports:", error);
193
+ }
194
+ };
195
+
196
+ const getPortDisplayName = (port: SerialPort): string => {
197
+ try {
198
+ const info = port.getInfo();
199
+ if (info.usbVendorId && info.usbProductId) {
200
+ return `USB Port (${info.usbVendorId}:${info.usbProductId})`;
201
+ }
202
+ if (info.usbVendorId) {
203
+ return `Serial Port (VID:${info.usbVendorId
204
+ .toString(16)
205
+ .toUpperCase()})`;
206
+ }
207
+ } catch (error) {
208
+ // getInfo() might not be available
209
+ }
210
+ return `Serial Port ${Date.now()}`;
211
+ };
212
+
213
+ const handleConnect = async () => {
214
+ if (!isWebSerialSupported()) {
215
+ setError("Web Serial API is not supported in this browser");
216
+ return;
217
+ }
218
+
219
+ try {
220
+ setIsConnecting(true);
221
+ setError(null);
222
+
223
+ // Step 1: Request Web Serial port
224
+ console.log("Step 1: Requesting Web Serial port...");
225
+ const port = await navigator.serial.requestPort();
226
+ await port.open({ baudRate: 1000000 });
227
+
228
+ // Step 2: Request WebUSB device for metadata
229
+ console.log(
230
+ "Step 2: Requesting WebUSB device for unique identification..."
231
+ );
232
+ let serialNumber = null;
233
+ let usbMetadata = null;
234
+
235
+ try {
236
+ // Request USB device access for metadata
237
+ const usbDevice = await navigator.usb.requestDevice({
238
+ filters: [
239
+ { vendorId: 0x0403 }, // FTDI
240
+ { vendorId: 0x067b }, // Prolific
241
+ { vendorId: 0x10c4 }, // Silicon Labs
242
+ { vendorId: 0x1a86 }, // QinHeng Electronics (CH340)
243
+ { vendorId: 0x239a }, // Adafruit
244
+ { vendorId: 0x2341 }, // Arduino
245
+ { vendorId: 0x2e8a }, // Raspberry Pi Foundation
246
+ { vendorId: 0x1b4f }, // SparkFun
247
+ ],
248
+ });
249
+
250
+ if (usbDevice) {
251
+ serialNumber =
252
+ usbDevice.serialNumber ||
253
+ `${usbDevice.vendorId}-${usbDevice.productId}-${Date.now()}`;
254
+ usbMetadata = {
255
+ vendorId: `0x${usbDevice.vendorId.toString(16).padStart(4, "0")}`,
256
+ productId: `0x${usbDevice.productId.toString(16).padStart(4, "0")}`,
257
+ serialNumber: usbDevice.serialNumber || "Generated ID",
258
+ manufacturerName: usbDevice.manufacturerName || "Unknown",
259
+ productName: usbDevice.productName || "Unknown",
260
+ usbVersionMajor: usbDevice.usbVersionMajor,
261
+ usbVersionMinor: usbDevice.usbVersionMinor,
262
+ deviceClass: usbDevice.deviceClass,
263
+ deviceSubclass: usbDevice.deviceSubclass,
264
+ deviceProtocol: usbDevice.deviceProtocol,
265
+ };
266
+ console.log("✅ USB device metadata acquired:", usbMetadata);
267
+ }
268
+ } catch (usbError) {
269
+ console.log(
270
+ "⚠️ WebUSB request failed, generating fallback ID:",
271
+ usbError
272
+ );
273
+ // Generate a fallback unique ID if WebUSB fails
274
+ serialNumber = `fallback-${Date.now()}-${Math.random()
275
+ .toString(36)
276
+ .substr(2, 9)}`;
277
+ usbMetadata = {
278
+ vendorId: "Unknown",
279
+ productId: "Unknown",
280
+ serialNumber: serialNumber,
281
+ manufacturerName: "USB Metadata Not Available",
282
+ productName: "Check browser WebUSB support",
283
+ };
284
+ }
285
+
286
+ const portName = getPortDisplayName(port);
287
+
288
+ // Step 3: Check if this robot (by serial number) is already connected
289
+ const existingIndex = connectedRobots.findIndex(
290
+ (robot) => robot.serialNumber === serialNumber
291
+ );
292
+
293
+ if (existingIndex === -1) {
294
+ // New robot - add to list
295
+ const newRobot: RobotConnection = {
296
+ port,
297
+ name: portName,
298
+ isConnected: true,
299
+ serialNumber: serialNumber!,
300
+ usbMetadata: usbMetadata || undefined,
301
+ };
302
+
303
+ // Try to load saved robot info by serial number using unified storage
304
+ if (serialNumber) {
305
+ try {
306
+ const { getRobotConfig } = await import("../lib/unified-storage");
307
+ const savedConfig = getRobotConfig(serialNumber);
308
+ if (savedConfig) {
309
+ newRobot.robotType = savedConfig.robotType as
310
+ | "so100_follower"
311
+ | "so100_leader";
312
+ newRobot.robotId = savedConfig.robotId;
313
+ console.log("📋 Loaded saved robot configuration:", savedConfig);
314
+ }
315
+ } catch (error) {
316
+ console.warn("Failed to load saved robot data:", error);
317
+ }
318
+ }
319
+
320
+ onConnectedRobotsChange([...connectedRobots, newRobot]);
321
+ console.log("🤖 New robot connected with ID:", serialNumber);
322
+ } else {
323
+ // Existing robot - update port and connection status
324
+ const updatedRobots = connectedRobots.map((robot, index) =>
325
+ index === existingIndex
326
+ ? { ...robot, port, isConnected: true, name: portName }
327
+ : robot
328
+ );
329
+ onConnectedRobotsChange(updatedRobots);
330
+ console.log("🔄 Existing robot reconnected:", serialNumber);
331
+ }
332
+ } catch (error) {
333
+ if (
334
+ error instanceof Error &&
335
+ (error.message.includes("cancelled") ||
336
+ error.message.includes("No port selected by the user") ||
337
+ error.name === "NotAllowedError")
338
+ ) {
339
+ // User cancelled - no error message needed, just log to console
340
+ console.log("Connection cancelled by user");
341
+ return;
342
+ }
343
+ setError(
344
+ error instanceof Error ? error.message : "Failed to connect to robot"
345
+ );
346
+ } finally {
347
+ setIsConnecting(false);
348
+ }
349
+ };
350
+
351
+ const handleDisconnect = async (index: number) => {
352
+ const portInfo = connectedRobots[index];
353
+ const robotName = portInfo.robotId || portInfo.name;
354
+ const serialNumber = portInfo.serialNumber || "unknown";
355
+
356
+ // Show confirmation dialog
357
+ setConfirmDeleteDialog({
358
+ open: true,
359
+ robotIndex: index,
360
+ robotName,
361
+ serialNumber,
362
+ });
363
+ };
364
+
365
+ const confirmDelete = async () => {
366
+ const { robotIndex } = confirmDeleteDialog;
367
+ const portInfo = connectedRobots[robotIndex];
368
+
369
+ setConfirmDeleteDialog({
370
+ open: false,
371
+ robotIndex: -1,
372
+ robotName: "",
373
+ serialNumber: "",
374
+ });
375
+
376
+ try {
377
+ // Close the serial port connection
378
+ if (portInfo.isConnected) {
379
+ await portInfo.port.close();
380
+ }
381
+
382
+ // Delete from unified storage if serial number is available
383
+ if (portInfo.serialNumber) {
384
+ try {
385
+ const { getUnifiedKey } = await import("../lib/unified-storage");
386
+ const unifiedKey = getUnifiedKey(portInfo.serialNumber);
387
+
388
+ // Remove unified storage data
389
+ localStorage.removeItem(unifiedKey);
390
+ console.log(`🗑️ Deleted unified robot data: ${unifiedKey}`);
391
+ } catch (error) {
392
+ console.warn("Failed to delete unified storage data:", error);
393
+ }
394
+ }
395
+
396
+ // Remove from UI
397
+ const updatedRobots = connectedRobots.filter((_, i) => i !== robotIndex);
398
+ onConnectedRobotsChange(updatedRobots);
399
+
400
+ console.log(
401
+ `✅ Robot "${confirmDeleteDialog.robotName}" permanently removed from system`
402
+ );
403
+ } catch (error) {
404
+ setError(
405
+ error instanceof Error ? error.message : "Failed to remove robot"
406
+ );
407
+ }
408
+ };
409
+
410
+ const cancelDelete = () => {
411
+ setConfirmDeleteDialog({
412
+ open: false,
413
+ robotIndex: -1,
414
+ robotName: "",
415
+ serialNumber: "",
416
+ });
417
+ };
418
+
419
+ const handleUpdatePortInfo = (
420
+ index: number,
421
+ robotType: "so100_follower" | "so100_leader",
422
+ robotId: string
423
+ ) => {
424
+ const updatedRobots = connectedRobots.map((robot, i) => {
425
+ if (i === index) {
426
+ const updatedRobot = { ...robot, robotType, robotId };
427
+
428
+ // Save robot configuration using unified storage
429
+ if (updatedRobot.serialNumber) {
430
+ import("../lib/unified-storage")
431
+ .then(({ saveRobotConfig }) => {
432
+ saveRobotConfig(
433
+ updatedRobot.serialNumber!,
434
+ robotType,
435
+ robotId,
436
+ updatedRobot.usbMetadata
437
+ );
438
+ console.log(
439
+ "💾 Saved robot configuration for:",
440
+ updatedRobot.serialNumber
441
+ );
442
+ })
443
+ .catch((error) => {
444
+ console.warn("Failed to save robot configuration:", error);
445
+ });
446
+ }
447
+
448
+ return updatedRobot;
449
+ }
450
+ return robot;
451
+ });
452
+ onConnectedRobotsChange(updatedRobots);
453
+ };
454
+
455
+ const handleFindPorts = async () => {
456
+ if (!isWebSerialSupported()) {
457
+ setError("Web Serial API is not supported in this browser");
458
+ return;
459
+ }
460
+
461
+ try {
462
+ setIsFindingPorts(true);
463
+ setFindPortsLog([]);
464
+ setError(null);
465
+
466
+ // Use the new findPort API from standard library
467
+ const { findPort } = await import("@lerobot/web");
468
+
469
+ const findPortProcess = await findPort({
470
+ onMessage: (message) => {
471
+ setFindPortsLog((prev) => [...prev, message]);
472
+ },
473
+ });
474
+
475
+ const robotConnections = (await findPortProcess.result) as any; // RobotConnection[] from findPort
476
+ const robotConnection = robotConnections[0]; // Get first robot from array
477
+
478
+ const portName = getPortDisplayName(robotConnection.port);
479
+ setFindPortsLog((prev) => [...prev, `✅ Port ready: ${portName}`]);
480
+
481
+ // Add to connected ports if not already there
482
+ const existingIndex = connectedRobots.findIndex(
483
+ (p) => p.name === portName
484
+ );
485
+ if (existingIndex === -1) {
486
+ const newPort: RobotConnection = {
487
+ port: robotConnection.port,
488
+ name: portName,
489
+ isConnected: true,
490
+ robotType: robotConnection.robotType,
491
+ robotId: robotConnection.robotId,
492
+ serialNumber: robotConnection.serialNumber,
493
+ };
494
+ onConnectedRobotsChange([...connectedRobots, newPort]);
495
+ }
496
+ } catch (error) {
497
+ if (
498
+ error instanceof Error &&
499
+ (error.message.includes("cancelled") ||
500
+ error.name === "NotAllowedError")
501
+ ) {
502
+ // User cancelled - no message needed, just log to console
503
+ console.log("Port identification cancelled by user");
504
+ return;
505
+ }
506
+ setError(error instanceof Error ? error.message : "Failed to find ports");
507
+ } finally {
508
+ setIsFindingPorts(false);
509
+ }
510
+ };
511
+
512
+ const ensurePortIsOpen = async (robotIndex: number) => {
513
+ const robot = connectedRobots[robotIndex];
514
+ if (!robot) return false;
515
+
516
+ try {
517
+ // If port is already open, we're good
518
+ if (robot.port.readable !== null && robot.port.writable !== null) {
519
+ return true;
520
+ }
521
+
522
+ // Try to open the port
523
+ await robot.port.open({ baudRate: 1000000 });
524
+
525
+ // Update the robot's connection status
526
+ const updatedRobots = connectedRobots.map((r, i) =>
527
+ i === robotIndex ? { ...r, isConnected: true } : r
528
+ );
529
+ onConnectedRobotsChange(updatedRobots);
530
+
531
+ return true;
532
+ } catch (error) {
533
+ console.error("Failed to open port for calibration:", error);
534
+ setError(error instanceof Error ? error.message : "Failed to open port");
535
+ return false;
536
+ }
537
+ };
538
+
539
+ const handleCalibrate = async (port: RobotConnection) => {
540
+ if (!port.robotType || !port.robotId) {
541
+ setError("Please set robot type and ID before calibrating");
542
+ return;
543
+ }
544
+
545
+ // Find the robot index
546
+ const robotIndex = connectedRobots.findIndex((r) => r.port === port.port);
547
+ if (robotIndex === -1) {
548
+ setError("Robot not found in connected robots list");
549
+ return;
550
+ }
551
+
552
+ // Ensure port is open before calibrating
553
+ const isOpen = await ensurePortIsOpen(robotIndex);
554
+ if (!isOpen) {
555
+ return; // Error already set in ensurePortIsOpen
556
+ }
557
+
558
+ if (onCalibrate) {
559
+ onCalibrate(port.port, port.robotType, port.robotId);
560
+ }
561
+ };
562
+
563
+ return (
564
+ <Card>
565
+ <CardHeader>
566
+ <CardTitle>🔌 Robot Connection Manager</CardTitle>
567
+ <CardDescription>
568
+ Connect, identify, and manage your robot arms
569
+ </CardDescription>
570
+ </CardHeader>
571
+ <CardContent>
572
+ <div className="space-y-6">
573
+ {/* Error Display */}
574
+ {error && (
575
+ <Alert variant="destructive">
576
+ <AlertDescription>{error}</AlertDescription>
577
+ </Alert>
578
+ )}
579
+
580
+ {/* Connection Controls */}
581
+ <div className="flex gap-2">
582
+ <Button
583
+ onClick={handleConnect}
584
+ disabled={isConnecting || !isWebSerialSupported()}
585
+ className="flex-1"
586
+ >
587
+ {isConnecting ? "Connecting..." : "Connect Robot"}
588
+ </Button>
589
+ <Button
590
+ variant="outline"
591
+ onClick={handleFindPorts}
592
+ disabled={isFindingPorts || !isWebSerialSupported()}
593
+ className="flex-1"
594
+ >
595
+ {isFindingPorts ? "Finding..." : "Find Port"}
596
+ </Button>
597
+ </div>
598
+
599
+ {/* Find Ports Log */}
600
+ {findPortsLog.length > 0 && (
601
+ <div className="bg-gray-50 p-3 rounded-md text-sm space-y-1">
602
+ {findPortsLog.map((log, index) => (
603
+ <div key={index} className="text-gray-700">
604
+ {log}
605
+ </div>
606
+ ))}
607
+ </div>
608
+ )}
609
+
610
+ {/* Connected Ports */}
611
+ <div>
612
+ <h4 className="font-semibold mb-3">
613
+ Connected Robots ({connectedRobots.length})
614
+ </h4>
615
+
616
+ {connectedRobots.length === 0 ? (
617
+ <div className="text-center py-8 text-gray-500">
618
+ <div className="text-2xl mb-2">🤖</div>
619
+ <p>No robots connected</p>
620
+ <p className="text-xs">
621
+ Use "Connect Robot" or "Find Port" to add robots
622
+ </p>
623
+ </div>
624
+ ) : (
625
+ <div className="space-y-4">
626
+ {connectedRobots.map((portInfo, index) => (
627
+ <PortCard
628
+ key={index}
629
+ portInfo={portInfo}
630
+ onDisconnect={() => handleDisconnect(index)}
631
+ onUpdateInfo={(robotType, robotId) =>
632
+ handleUpdatePortInfo(index, robotType, robotId)
633
+ }
634
+ onCalibrate={() => handleCalibrate(portInfo)}
635
+ onTeleoperate={() => onTeleoperate?.(portInfo)}
636
+ />
637
+ ))}
638
+ </div>
639
+ )}
640
+ </div>
641
+ </div>
642
+ </CardContent>
643
+
644
+ {/* Confirmation Dialog */}
645
+ <Dialog open={confirmDeleteDialog.open} onOpenChange={cancelDelete}>
646
+ <DialogContent>
647
+ <DialogHeader>
648
+ <DialogTitle>🗑️ Permanently Delete Robot Data?</DialogTitle>
649
+ <DialogDescription>
650
+ This action cannot be undone. All robot data will be permanently
651
+ deleted.
652
+ </DialogDescription>
653
+ </DialogHeader>
654
+
655
+ <div className="space-y-3">
656
+ <div className="p-4 bg-red-50 rounded-lg border border-red-200">
657
+ <div className="font-medium text-red-900 mb-2">
658
+ Robot Information:
659
+ </div>
660
+ <div className="text-sm text-red-800 space-y-1">
661
+ <div>
662
+ • Name:{" "}
663
+ <span className="font-mono">
664
+ {confirmDeleteDialog.robotName}
665
+ </span>
666
+ </div>
667
+ <div>
668
+ • Serial:{" "}
669
+ <span className="font-mono">
670
+ {confirmDeleteDialog.serialNumber}
671
+ </span>
672
+ </div>
673
+ </div>
674
+ </div>
675
+
676
+ <div className="p-4 bg-red-50 rounded-lg border border-red-200">
677
+ <div className="font-medium text-red-900 mb-2">
678
+ This will permanently delete:
679
+ </div>
680
+ <div className="text-sm text-red-800 space-y-1">
681
+ <div>• Robot configuration</div>
682
+ <div>• Calibration data</div>
683
+ <div>• All saved settings</div>
684
+ </div>
685
+ </div>
686
+ </div>
687
+
688
+ <DialogFooter>
689
+ <Button variant="outline" onClick={cancelDelete}>
690
+ Cancel
691
+ </Button>
692
+ <Button variant="destructive" onClick={confirmDelete}>
693
+ Delete Forever
694
+ </Button>
695
+ </DialogFooter>
696
+ </DialogContent>
697
+ </Dialog>
698
+ </Card>
699
+ );
700
+ }
701
+
702
+ interface PortCardProps {
703
+ portInfo: RobotConnection;
704
+ onDisconnect: () => void;
705
+ onUpdateInfo: (
706
+ robotType: "so100_follower" | "so100_leader",
707
+ robotId: string
708
+ ) => void;
709
+ onCalibrate: () => void;
710
+ onTeleoperate: () => void;
711
+ }
712
+
713
+ function PortCard({
714
+ portInfo,
715
+ onDisconnect,
716
+ onUpdateInfo,
717
+ onCalibrate,
718
+ onTeleoperate,
719
+ }: PortCardProps) {
720
+ const [robotType, setRobotType] = useState<"so100_follower" | "so100_leader">(
721
+ portInfo.robotType || "so100_follower"
722
+ );
723
+ const [robotId, setRobotId] = useState(portInfo.robotId || "");
724
+ const [isEditing, setIsEditing] = useState(false);
725
+ const [isScanning, setIsScanning] = useState(false);
726
+ const [motorIDs, setMotorIDs] = useState<number[]>([]);
727
+ const [portMetadata, setPortMetadata] = useState<any>(null);
728
+ const [showDeviceInfo, setShowDeviceInfo] = useState(false);
729
+
730
+ // Check for calibration using unified storage
731
+ const getCalibrationStatus = () => {
732
+ // Use the same serial number logic as calibration: prefer main serialNumber, fallback to USB metadata, then "unknown"
733
+ const serialNumber =
734
+ portInfo.serialNumber || portInfo.usbMetadata?.serialNumber || "unknown";
735
+
736
+ try {
737
+ // Use unified storage system with automatic migration
738
+ import("../lib/unified-storage")
739
+ .then(({ getCalibrationStatus }) => {
740
+ const status = getCalibrationStatus(serialNumber);
741
+ return status;
742
+ })
743
+ .catch((error) => {
744
+ console.warn("Failed to load unified calibration data:", error);
745
+ return null;
746
+ });
747
+
748
+ // For immediate synchronous return, try to get existing unified data first
749
+ const unifiedKey = `lerobotjs-${serialNumber}`;
750
+ const existing = localStorage.getItem(unifiedKey);
751
+ if (existing) {
752
+ const data = JSON.parse(existing);
753
+ if (data.calibration?.metadata) {
754
+ return {
755
+ timestamp: data.calibration.metadata.timestamp,
756
+ readCount: data.calibration.metadata.readCount,
757
+ };
758
+ }
759
+ }
760
+ } catch (error) {
761
+ console.warn("Failed to read calibration from unified storage:", error);
762
+ }
763
+ return null;
764
+ };
765
+
766
+ const calibrationStatus = getCalibrationStatus();
767
+
768
+ const handleSave = () => {
769
+ if (robotId.trim()) {
770
+ onUpdateInfo(robotType, robotId.trim());
771
+ setIsEditing(false);
772
+ }
773
+ };
774
+
775
+ // Use current values (either from props or local state)
776
+ const currentRobotType = portInfo.robotType || robotType;
777
+ const currentRobotId = portInfo.robotId || robotId;
778
+
779
+ const handleCancel = () => {
780
+ setRobotType(portInfo.robotType || "so100_follower");
781
+ setRobotId(portInfo.robotId || "");
782
+ setIsEditing(false);
783
+ };
784
+
785
+ // Scan for motor IDs and gather USB device metadata
786
+ const scanDeviceInfo = async () => {
787
+ if (!portInfo.port || !portInfo.isConnected) {
788
+ console.warn("Port not connected");
789
+ return;
790
+ }
791
+
792
+ setIsScanning(true);
793
+ setMotorIDs([]);
794
+ setPortMetadata(null);
795
+ const foundIDs: number[] = [];
796
+
797
+ try {
798
+ // Try to get USB device info using WebUSB for better metadata
799
+ let usbDeviceInfo = null;
800
+
801
+ try {
802
+ // First, check if we already have USB device permissions
803
+ let usbDevices = await navigator.usb.getDevices();
804
+ console.log("Already permitted USB devices:", usbDevices);
805
+
806
+ // If no devices found, request permission for USB-to-serial devices
807
+ if (usbDevices.length === 0) {
808
+ console.log(
809
+ "No USB permissions yet, requesting access to USB-to-serial devices..."
810
+ );
811
+
812
+ // Request access to common USB-to-serial chips
813
+ try {
814
+ const device = await navigator.usb.requestDevice({
815
+ filters: [
816
+ { vendorId: 0x0403 }, // FTDI
817
+ { vendorId: 0x067b }, // Prolific
818
+ { vendorId: 0x10c4 }, // Silicon Labs
819
+ { vendorId: 0x1a86 }, // QinHeng Electronics (CH340)
820
+ { vendorId: 0x239a }, // Adafruit
821
+ { vendorId: 0x2341 }, // Arduino
822
+ { vendorId: 0x2e8a }, // Raspberry Pi Foundation
823
+ { vendorId: 0x1b4f }, // SparkFun
824
+ ],
825
+ });
826
+
827
+ if (device) {
828
+ usbDevices = [device];
829
+ console.log("USB device access granted:", device);
830
+ }
831
+ } catch (requestError) {
832
+ console.log(
833
+ "User cancelled USB device selection or no devices found"
834
+ );
835
+ // Try requesting any device as fallback
836
+ try {
837
+ const anyDevice = await navigator.usb.requestDevice({
838
+ filters: [], // Allow any USB device
839
+ });
840
+ if (anyDevice) {
841
+ usbDevices = [anyDevice];
842
+ console.log("Fallback USB device selected:", anyDevice);
843
+ }
844
+ } catch (fallbackError) {
845
+ console.log("No USB device selected");
846
+ }
847
+ }
848
+ }
849
+
850
+ // Try to match with Web Serial port (this is tricky, so we'll take the first available)
851
+ if (usbDevices.length > 0) {
852
+ // Look for common USB-to-serial chip vendor IDs
853
+ const serialChipVendors = [
854
+ 0x0403, // FTDI
855
+ 0x067b, // Prolific
856
+ 0x10c4, // Silicon Labs
857
+ 0x1a86, // QinHeng Electronics (CH340)
858
+ 0x239a, // Adafruit
859
+ 0x2341, // Arduino
860
+ 0x2e8a, // Raspberry Pi Foundation
861
+ 0x1b4f, // SparkFun
862
+ ];
863
+
864
+ const serialDevice =
865
+ usbDevices.find((device) =>
866
+ serialChipVendors.includes(device.vendorId)
867
+ ) || usbDevices[0]; // Fallback to first device
868
+
869
+ if (serialDevice) {
870
+ usbDeviceInfo = {
871
+ vendorId: `0x${serialDevice.vendorId
872
+ .toString(16)
873
+ .padStart(4, "0")}`,
874
+ productId: `0x${serialDevice.productId
875
+ .toString(16)
876
+ .padStart(4, "0")}`,
877
+ serialNumber: serialDevice.serialNumber || "Not available",
878
+ manufacturerName: serialDevice.manufacturerName || "Unknown",
879
+ productName: serialDevice.productName || "Unknown",
880
+ usbVersionMajor: serialDevice.usbVersionMajor,
881
+ usbVersionMinor: serialDevice.usbVersionMinor,
882
+ deviceClass: serialDevice.deviceClass,
883
+ deviceSubclass: serialDevice.deviceSubclass,
884
+ deviceProtocol: serialDevice.deviceProtocol,
885
+ };
886
+ console.log("USB device info:", usbDeviceInfo);
887
+ }
888
+ }
889
+ } catch (usbError) {
890
+ console.log("WebUSB not available or no permissions:", usbError);
891
+ // Fallback to Web Serial API info
892
+ const portInfo_metadata = portInfo.port.getInfo();
893
+ console.log("Serial port metadata fallback:", portInfo_metadata);
894
+ if (Object.keys(portInfo_metadata).length > 0) {
895
+ usbDeviceInfo = {
896
+ vendorId: portInfo_metadata.usbVendorId
897
+ ? `0x${portInfo_metadata.usbVendorId
898
+ .toString(16)
899
+ .padStart(4, "0")}`
900
+ : "Not available",
901
+ productId: portInfo_metadata.usbProductId
902
+ ? `0x${portInfo_metadata.usbProductId
903
+ .toString(16)
904
+ .padStart(4, "0")}`
905
+ : "Not available",
906
+ serialNumber: "Not available via Web Serial",
907
+ manufacturerName: "Not available via Web Serial",
908
+ productName: "Not available via Web Serial",
909
+ };
910
+ }
911
+ }
912
+
913
+ setPortMetadata(usbDeviceInfo);
914
+
915
+ // Get reader/writer for the port
916
+ const reader = portInfo.port.readable?.getReader();
917
+ const writer = portInfo.port.writable?.getWriter();
918
+
919
+ if (!reader || !writer) {
920
+ console.warn("Cannot access port reader/writer");
921
+ setShowDeviceInfo(true);
922
+ return;
923
+ }
924
+
925
+ // Test motor IDs 1-10 (common range for servos)
926
+ for (let motorId = 1; motorId <= 10; motorId++) {
927
+ try {
928
+ // Create STS3215 ping packet
929
+ const packet = new Uint8Array([
930
+ 0xff,
931
+ 0xff,
932
+ motorId,
933
+ 0x02,
934
+ 0x01,
935
+ 0x00,
936
+ ]);
937
+ const checksum = ~(motorId + 0x02 + 0x01) & 0xff;
938
+ packet[5] = checksum;
939
+
940
+ // Send ping
941
+ await writer.write(packet);
942
+
943
+ // Wait a bit for response
944
+ await new Promise((resolve) => setTimeout(resolve, 20));
945
+
946
+ // Try to read response with timeout
947
+ const timeoutPromise = new Promise((_, reject) =>
948
+ setTimeout(() => reject(new Error("Timeout")), 50)
949
+ );
950
+
951
+ try {
952
+ const result = (await Promise.race([
953
+ reader.read(),
954
+ timeoutPromise,
955
+ ])) as ReadableStreamReadResult<Uint8Array>;
956
+
957
+ if (
958
+ result &&
959
+ !result.done &&
960
+ result.value &&
961
+ result.value.length >= 6
962
+ ) {
963
+ const response = result.value;
964
+ const responseId = response[2];
965
+
966
+ // If we got a response with matching ID, motor exists
967
+ if (responseId === motorId) {
968
+ foundIDs.push(motorId);
969
+ }
970
+ }
971
+ } catch (readError) {
972
+ // No response from this motor ID - that's normal
973
+ }
974
+ } catch (error) {
975
+ console.warn(`Error testing motor ID ${motorId}:`, error);
976
+ }
977
+
978
+ // Small delay between tests
979
+ await new Promise((resolve) => setTimeout(resolve, 10));
980
+ }
981
+
982
+ reader.releaseLock();
983
+ writer.releaseLock();
984
+
985
+ setMotorIDs(foundIDs);
986
+ setShowDeviceInfo(true);
987
+ } catch (error) {
988
+ console.error("Device info scan failed:", error);
989
+ } finally {
990
+ setIsScanning(false);
991
+ }
992
+ };
993
+
994
+ return (
995
+ <div className="border rounded-lg p-4 space-y-3">
996
+ {/* Header with port name and status */}
997
+ <div className="flex items-center justify-between">
998
+ <div className="flex items-center space-x-2">
999
+ <div className="flex flex-col">
1000
+ <span className="font-medium">{portInfo.name}</span>
1001
+ {portInfo.serialNumber && (
1002
+ <span className="text-xs text-gray-500 font-mono">
1003
+ ID:{" "}
1004
+ {portInfo.serialNumber.length > 20
1005
+ ? portInfo.serialNumber.substring(0, 20) + "..."
1006
+ : portInfo.serialNumber}
1007
+ </span>
1008
+ )}
1009
+ </div>
1010
+ <Badge variant={portInfo.isConnected ? "default" : "outline"}>
1011
+ {portInfo.isConnected ? "Connected" : "Available"}
1012
+ </Badge>
1013
+ </div>
1014
+ <Button variant="destructive" size="sm" onClick={onDisconnect}>
1015
+ Remove
1016
+ </Button>
1017
+ </div>
1018
+
1019
+ {/* Robot Info Display (when not editing) */}
1020
+ {!isEditing && currentRobotType && currentRobotId && (
1021
+ <div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
1022
+ <div className="flex items-center space-x-3">
1023
+ <div>
1024
+ <div className="font-medium text-sm">{currentRobotId}</div>
1025
+ <div className="text-xs text-gray-600">
1026
+ {currentRobotType.replace("_", " ")}
1027
+ </div>
1028
+ </div>
1029
+ {calibrationStatus && (
1030
+ <Badge variant="default" className="bg-green-100 text-green-800">
1031
+ ✅ Calibrated
1032
+ </Badge>
1033
+ )}
1034
+ </div>
1035
+ <Button
1036
+ variant="outline"
1037
+ size="sm"
1038
+ onClick={() => setIsEditing(true)}
1039
+ >
1040
+ Edit
1041
+ </Button>
1042
+ </div>
1043
+ )}
1044
+
1045
+ {/* Setup prompt for unconfigured robots */}
1046
+ {!isEditing && (!currentRobotType || !currentRobotId) && (
1047
+ <div className="flex items-center justify-between p-3 bg-blue-50 rounded-lg">
1048
+ <div className="text-sm text-blue-800">
1049
+ Robot needs configuration before use
1050
+ </div>
1051
+ <Button
1052
+ variant="outline"
1053
+ size="sm"
1054
+ onClick={() => setIsEditing(true)}
1055
+ >
1056
+ Configure
1057
+ </Button>
1058
+ </div>
1059
+ )}
1060
+
1061
+ {/* Robot Configuration Form (when editing) */}
1062
+ {isEditing && (
1063
+ <div className="space-y-3 p-3 bg-gray-50 rounded-lg">
1064
+ <div className="grid grid-cols-2 gap-3">
1065
+ <div>
1066
+ <label className="text-sm font-medium block mb-1">
1067
+ Robot Type
1068
+ </label>
1069
+ <select
1070
+ value={robotType}
1071
+ onChange={(e) =>
1072
+ setRobotType(
1073
+ e.target.value as "so100_follower" | "so100_leader"
1074
+ )
1075
+ }
1076
+ className="w-full px-2 py-1 border rounded text-sm"
1077
+ >
1078
+ <option value="so100_follower">SO-100 Follower</option>
1079
+ <option value="so100_leader">SO-100 Leader</option>
1080
+ </select>
1081
+ </div>
1082
+ <div>
1083
+ <label className="text-sm font-medium block mb-1">Robot ID</label>
1084
+ <input
1085
+ type="text"
1086
+ value={robotId}
1087
+ onChange={(e) => setRobotId(e.target.value)}
1088
+ placeholder="e.g., my_robot"
1089
+ className="w-full px-2 py-1 border rounded text-sm"
1090
+ />
1091
+ </div>
1092
+ </div>
1093
+
1094
+ <div className="flex gap-2">
1095
+ <Button size="sm" onClick={handleSave} disabled={!robotId.trim()}>
1096
+ Save
1097
+ </Button>
1098
+ <Button size="sm" variant="outline" onClick={handleCancel}>
1099
+ Cancel
1100
+ </Button>
1101
+ </div>
1102
+ </div>
1103
+ )}
1104
+
1105
+ {/* Calibration Status and Action */}
1106
+ {currentRobotType && currentRobotId && (
1107
+ <div className="space-y-3">
1108
+ <div className="flex items-center justify-between">
1109
+ <div className="text-sm text-gray-600">
1110
+ {calibrationStatus ? (
1111
+ <span>
1112
+ Last calibrated:{" "}
1113
+ {new Date(calibrationStatus.timestamp).toLocaleDateString()}
1114
+ <span className="text-xs ml-1">
1115
+ ({calibrationStatus.readCount} readings)
1116
+ </span>
1117
+ </span>
1118
+ ) : (
1119
+ <span>Not calibrated yet</span>
1120
+ )}
1121
+ </div>
1122
+ <div className="flex gap-2">
1123
+ <Button
1124
+ size="sm"
1125
+ variant={calibrationStatus ? "outline" : "default"}
1126
+ onClick={onCalibrate}
1127
+ disabled={!currentRobotType || !currentRobotId}
1128
+ >
1129
+ {calibrationStatus ? "Re-calibrate" : "Calibrate"}
1130
+ </Button>
1131
+ <Button
1132
+ size="sm"
1133
+ variant="outline"
1134
+ onClick={onTeleoperate}
1135
+ disabled={
1136
+ !currentRobotType || !currentRobotId || !portInfo.isConnected
1137
+ }
1138
+ >
1139
+ 🎮 Teleoperate
1140
+ </Button>
1141
+ </div>
1142
+ </div>
1143
+
1144
+ {/* Device Info Scanner */}
1145
+ <div className="flex items-center justify-between">
1146
+ <div className="text-sm text-gray-600">
1147
+ Scan device info and motor IDs
1148
+ </div>
1149
+ <Button
1150
+ size="sm"
1151
+ variant="outline"
1152
+ onClick={scanDeviceInfo}
1153
+ disabled={!portInfo.isConnected || isScanning}
1154
+ >
1155
+ {isScanning ? "Scanning..." : "Show Device Info"}
1156
+ </Button>
1157
+ </div>
1158
+
1159
+ {/* Device Info Results */}
1160
+ {showDeviceInfo && (
1161
+ <div className="p-3 bg-gray-50 rounded-lg space-y-3">
1162
+ {/* USB Device Information */}
1163
+ {portMetadata && (
1164
+ <div>
1165
+ <div className="text-sm font-medium mb-2">
1166
+ 📱 USB Device Info:
1167
+ </div>
1168
+ <div className="space-y-1 text-xs">
1169
+ <div className="flex justify-between">
1170
+ <span className="text-gray-600">Vendor ID:</span>
1171
+ <span className="font-mono">{portMetadata.vendorId}</span>
1172
+ </div>
1173
+ <div className="flex justify-between">
1174
+ <span className="text-gray-600">Product ID:</span>
1175
+ <span className="font-mono">
1176
+ {portMetadata.productId}
1177
+ </span>
1178
+ </div>
1179
+ <div className="flex justify-between">
1180
+ <span className="text-gray-600">Serial Number:</span>
1181
+ <span className="font-mono text-green-600 font-semibold">
1182
+ {portMetadata.serialNumber}
1183
+ </span>
1184
+ </div>
1185
+ <div className="flex justify-between">
1186
+ <span className="text-gray-600">Manufacturer:</span>
1187
+ <span>{portMetadata.manufacturerName}</span>
1188
+ </div>
1189
+ <div className="flex justify-between">
1190
+ <span className="text-gray-600">Product:</span>
1191
+ <span>{portMetadata.productName}</span>
1192
+ </div>
1193
+ {portMetadata.usbVersionMajor && (
1194
+ <div className="flex justify-between">
1195
+ <span className="text-gray-600">USB Version:</span>
1196
+ <span>
1197
+ {portMetadata.usbVersionMajor}.
1198
+ {portMetadata.usbVersionMinor}
1199
+ </span>
1200
+ </div>
1201
+ )}
1202
+ {portMetadata.deviceClass !== undefined && (
1203
+ <div className="flex justify-between">
1204
+ <span className="text-gray-600">Device Class:</span>
1205
+ <span>
1206
+ 0x
1207
+ {portMetadata.deviceClass
1208
+ .toString(16)
1209
+ .padStart(2, "0")}
1210
+ </span>
1211
+ </div>
1212
+ )}
1213
+ </div>
1214
+ </div>
1215
+ )}
1216
+
1217
+ {/* Motor IDs */}
1218
+ <div>
1219
+ <div className="text-sm font-medium mb-2">
1220
+ 🤖 Found Motor IDs:
1221
+ </div>
1222
+ {motorIDs.length > 0 ? (
1223
+ <div className="flex flex-wrap gap-2">
1224
+ {motorIDs.map((id) => (
1225
+ <Badge key={id} variant="outline" className="text-xs">
1226
+ Motor {id}
1227
+ </Badge>
1228
+ ))}
1229
+ </div>
1230
+ ) : (
1231
+ <div className="text-sm text-gray-500">
1232
+ No motor IDs found. Check connection and power.
1233
+ </div>
1234
+ )}
1235
+ </div>
1236
+
1237
+ <Button
1238
+ size="sm"
1239
+ variant="outline"
1240
+ onClick={() => setShowDeviceInfo(false)}
1241
+ className="mt-2 text-xs"
1242
+ >
1243
+ Hide
1244
+ </Button>
1245
+ </div>
1246
+ )}
1247
+ </div>
1248
+ )}
1249
+ </div>
1250
+ );
1251
+ }
examples/demo/components/TeleoperationPanel.tsx ADDED
@@ -0,0 +1,530 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useRef, useCallback } from "react";
2
+ import { Button } from "./ui/button";
3
+ import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
4
+ import { Badge } from "./ui/badge";
5
+ import { Alert, AlertDescription } from "./ui/alert";
6
+ import {
7
+ teleoperate,
8
+ type TeleoperationProcess,
9
+ type TeleoperationState,
10
+ } from "@lerobot/web";
11
+ import { getUnifiedRobotData } from "../lib/unified-storage";
12
+ import type { RobotConnection } from "@lerobot/web";
13
+ import { SO100_KEYBOARD_CONTROLS } from "@lerobot/web";
14
+
15
+ interface TeleoperationPanelProps {
16
+ robot: RobotConnection;
17
+ onClose: () => void;
18
+ }
19
+
20
+ export function TeleoperationPanel({
21
+ robot,
22
+ onClose,
23
+ }: TeleoperationPanelProps) {
24
+ const [teleoperationState, setTeleoperationState] =
25
+ useState<TeleoperationState>({
26
+ isActive: false,
27
+ motorConfigs: [],
28
+ lastUpdate: 0,
29
+ keyStates: {},
30
+ });
31
+ const [error, setError] = useState<string | null>(null);
32
+ const [, setIsInitialized] = useState(false);
33
+
34
+ const teleoperationProcessRef = useRef<TeleoperationProcess | null>(null);
35
+
36
+ // Initialize teleoperation process
37
+ useEffect(() => {
38
+ const initializeTeleoperation = async () => {
39
+ if (!robot || !robot.robotType) {
40
+ setError("No robot configuration available");
41
+ return;
42
+ }
43
+
44
+ try {
45
+ // Load calibration data from demo storage (app concern)
46
+ let calibrationData;
47
+ if (robot.serialNumber) {
48
+ const data = getUnifiedRobotData(robot.serialNumber);
49
+ calibrationData = data?.calibration;
50
+ if (calibrationData) {
51
+ console.log("✅ Loaded calibration data for", robot.serialNumber);
52
+ }
53
+ }
54
+
55
+ // Create teleoperation process using clean library API
56
+ const process = await teleoperate(robot, {
57
+ calibrationData,
58
+ onStateUpdate: (state: TeleoperationState) => {
59
+ setTeleoperationState(state);
60
+ },
61
+ });
62
+
63
+ teleoperationProcessRef.current = process;
64
+ setTeleoperationState(process.getState());
65
+ setIsInitialized(true);
66
+ setError(null);
67
+ } catch (error) {
68
+ const errorMessage =
69
+ error instanceof Error
70
+ ? error.message
71
+ : "Failed to initialize teleoperation";
72
+ setError(errorMessage);
73
+ console.error("❌ Failed to initialize teleoperation:", error);
74
+ }
75
+ };
76
+
77
+ initializeTeleoperation();
78
+
79
+ return () => {
80
+ // Cleanup on unmount
81
+ if (teleoperationProcessRef.current) {
82
+ teleoperationProcessRef.current.disconnect();
83
+ teleoperationProcessRef.current = null;
84
+ }
85
+ };
86
+ }, [robot]);
87
+
88
+ // Keyboard event handlers
89
+ const handleKeyDown = useCallback(
90
+ (event: KeyboardEvent) => {
91
+ if (!teleoperationState.isActive || !teleoperationProcessRef.current)
92
+ return;
93
+
94
+ const key = event.key;
95
+ event.preventDefault();
96
+ teleoperationProcessRef.current.updateKeyState(key, true);
97
+ },
98
+ [teleoperationState.isActive]
99
+ );
100
+
101
+ const handleKeyUp = useCallback(
102
+ (event: KeyboardEvent) => {
103
+ if (!teleoperationState.isActive || !teleoperationProcessRef.current)
104
+ return;
105
+
106
+ const key = event.key;
107
+ event.preventDefault();
108
+ teleoperationProcessRef.current.updateKeyState(key, false);
109
+ },
110
+ [teleoperationState.isActive]
111
+ );
112
+
113
+ // Register keyboard events
114
+ useEffect(() => {
115
+ if (teleoperationState.isActive) {
116
+ window.addEventListener("keydown", handleKeyDown);
117
+ window.addEventListener("keyup", handleKeyUp);
118
+
119
+ return () => {
120
+ window.removeEventListener("keydown", handleKeyDown);
121
+ window.removeEventListener("keyup", handleKeyUp);
122
+ };
123
+ }
124
+ }, [teleoperationState.isActive, handleKeyDown, handleKeyUp]);
125
+
126
+ const handleStart = () => {
127
+ if (!teleoperationProcessRef.current) {
128
+ setError("Teleoperation not initialized");
129
+ return;
130
+ }
131
+
132
+ try {
133
+ teleoperationProcessRef.current.start();
134
+ console.log("🎮 Teleoperation started");
135
+ } catch (error) {
136
+ const errorMessage =
137
+ error instanceof Error
138
+ ? error.message
139
+ : "Failed to start teleoperation";
140
+ setError(errorMessage);
141
+ }
142
+ };
143
+
144
+ const handleStop = () => {
145
+ if (!teleoperationProcessRef.current) return;
146
+
147
+ teleoperationProcessRef.current.stop();
148
+ console.log("🛑 Teleoperation stopped");
149
+ };
150
+
151
+ const handleClose = () => {
152
+ if (teleoperationProcessRef.current) {
153
+ teleoperationProcessRef.current.stop();
154
+ }
155
+ onClose();
156
+ };
157
+
158
+ const simulateKeyPress = (key: string) => {
159
+ if (!teleoperationProcessRef.current) return;
160
+ teleoperationProcessRef.current.updateKeyState(key, true);
161
+ };
162
+
163
+ const simulateKeyRelease = (key: string) => {
164
+ if (!teleoperationProcessRef.current) return;
165
+ teleoperationProcessRef.current.updateKeyState(key, false);
166
+ };
167
+
168
+ const moveMotorToPosition = async (motorIndex: number, position: number) => {
169
+ if (!teleoperationProcessRef.current) return;
170
+
171
+ try {
172
+ const motorName = teleoperationState.motorConfigs[motorIndex]?.name;
173
+ if (motorName) {
174
+ await teleoperationProcessRef.current.moveMotor(motorName, position);
175
+ }
176
+ } catch (error) {
177
+ console.warn(
178
+ `Failed to move motor ${motorIndex + 1} to position ${position}:`,
179
+ error
180
+ );
181
+ }
182
+ };
183
+
184
+ const isConnected = robot?.isConnected || false;
185
+ const isActive = teleoperationState.isActive;
186
+ const motorConfigs = teleoperationState.motorConfigs;
187
+ const keyStates = teleoperationState.keyStates;
188
+
189
+ // Virtual keyboard component
190
+ const VirtualKeyboard = () => {
191
+ const isKeyPressed = (key: string) => {
192
+ return keyStates[key]?.pressed || false;
193
+ };
194
+
195
+ const KeyButton = ({
196
+ keyCode,
197
+ children,
198
+ className = "",
199
+ size = "default" as "default" | "sm" | "lg" | "icon",
200
+ }: {
201
+ keyCode: string;
202
+ children: React.ReactNode;
203
+ className?: string;
204
+ size?: "default" | "sm" | "lg" | "icon";
205
+ }) => {
206
+ const control =
207
+ SO100_KEYBOARD_CONTROLS[
208
+ keyCode as keyof typeof SO100_KEYBOARD_CONTROLS
209
+ ];
210
+ const pressed = isKeyPressed(keyCode);
211
+
212
+ return (
213
+ <Button
214
+ variant={pressed ? "default" : "outline"}
215
+ size={size}
216
+ className={`
217
+ ${className}
218
+ ${
219
+ pressed
220
+ ? "bg-blue-600 text-white shadow-inner"
221
+ : "hover:bg-gray-100"
222
+ }
223
+ transition-all duration-75 font-mono text-xs
224
+ ${!isActive ? "opacity-50 cursor-not-allowed" : ""}
225
+ `}
226
+ disabled={!isActive}
227
+ onMouseDown={(e) => {
228
+ e.preventDefault();
229
+ if (isActive) simulateKeyPress(keyCode);
230
+ }}
231
+ onMouseUp={(e) => {
232
+ e.preventDefault();
233
+ if (isActive) simulateKeyRelease(keyCode);
234
+ }}
235
+ onMouseLeave={(e) => {
236
+ e.preventDefault();
237
+ if (isActive) simulateKeyRelease(keyCode);
238
+ }}
239
+ title={control?.description || keyCode}
240
+ >
241
+ {children}
242
+ </Button>
243
+ );
244
+ };
245
+
246
+ return (
247
+ <div className="space-y-4">
248
+ {/* Arrow Keys */}
249
+ <div className="text-center">
250
+ <h4 className="text-xs font-semibold mb-2 text-gray-600">Shoulder</h4>
251
+ <div className="flex flex-col items-center gap-1">
252
+ <KeyButton keyCode="ArrowUp" size="sm">
253
+
254
+ </KeyButton>
255
+ <div className="flex gap-1">
256
+ <KeyButton keyCode="ArrowLeft" size="sm">
257
+
258
+ </KeyButton>
259
+ <KeyButton keyCode="ArrowDown" size="sm">
260
+
261
+ </KeyButton>
262
+ <KeyButton keyCode="ArrowRight" size="sm">
263
+
264
+ </KeyButton>
265
+ </div>
266
+ </div>
267
+ </div>
268
+
269
+ {/* WASD Keys */}
270
+ <div className="text-center">
271
+ <h4 className="text-xs font-semibold mb-2 text-gray-600">
272
+ Elbow/Wrist
273
+ </h4>
274
+ <div className="flex flex-col items-center gap-1">
275
+ <KeyButton keyCode="w" size="sm">
276
+ W
277
+ </KeyButton>
278
+ <div className="flex gap-1">
279
+ <KeyButton keyCode="a" size="sm">
280
+ A
281
+ </KeyButton>
282
+ <KeyButton keyCode="s" size="sm">
283
+ S
284
+ </KeyButton>
285
+ <KeyButton keyCode="d" size="sm">
286
+ D
287
+ </KeyButton>
288
+ </div>
289
+ </div>
290
+ </div>
291
+
292
+ {/* Q/E and Gripper */}
293
+ <div className="flex justify-center gap-2">
294
+ <div className="text-center">
295
+ <h4 className="text-xs font-semibold mb-2 text-gray-600">Roll</h4>
296
+ <div className="flex gap-1">
297
+ <KeyButton keyCode="q" size="sm">
298
+ Q
299
+ </KeyButton>
300
+ <KeyButton keyCode="e" size="sm">
301
+ E
302
+ </KeyButton>
303
+ </div>
304
+ </div>
305
+ <div className="text-center">
306
+ <h4 className="text-xs font-semibold mb-2 text-gray-600">
307
+ Gripper
308
+ </h4>
309
+ <div className="flex gap-1">
310
+ <KeyButton keyCode="o" size="sm">
311
+ O
312
+ </KeyButton>
313
+ <KeyButton keyCode="c" size="sm">
314
+ C
315
+ </KeyButton>
316
+ </div>
317
+ </div>
318
+ </div>
319
+
320
+ {/* Emergency Stop */}
321
+ <div className="text-center border-t pt-2">
322
+ <KeyButton
323
+ keyCode="Escape"
324
+ className="bg-red-100 border-red-300 hover:bg-red-200 text-red-800 text-xs"
325
+ >
326
+ ESC
327
+ </KeyButton>
328
+ </div>
329
+ </div>
330
+ );
331
+ };
332
+
333
+ return (
334
+ <div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
335
+ <div className="container mx-auto px-6 py-8">
336
+ {/* Header */}
337
+ <div className="flex justify-between items-center mb-6">
338
+ <div>
339
+ <h1 className="text-3xl font-bold text-gray-900">
340
+ 🎮 Robot Teleoperation
341
+ </h1>
342
+ <p className="text-gray-600">
343
+ {robot.robotId || robot.name} - {robot.serialNumber}
344
+ </p>
345
+ </div>
346
+ <Button variant="outline" onClick={handleClose}>
347
+ ← Back to Dashboard
348
+ </Button>
349
+ </div>
350
+
351
+ {/* Error Alert */}
352
+ {error && (
353
+ <Alert variant="destructive" className="mb-6">
354
+ <AlertDescription>{error}</AlertDescription>
355
+ </Alert>
356
+ )}
357
+
358
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
359
+ {/* Status Panel */}
360
+ <Card>
361
+ <CardHeader>
362
+ <CardTitle className="flex items-center gap-2">
363
+ Status
364
+ <Badge variant={isConnected ? "default" : "destructive"}>
365
+ {isConnected ? "Connected" : "Disconnected"}
366
+ </Badge>
367
+ </CardTitle>
368
+ </CardHeader>
369
+ <CardContent className="space-y-4">
370
+ <div className="flex items-center justify-between">
371
+ <span className="text-sm text-gray-600">Teleoperation</span>
372
+ <Badge variant={isActive ? "default" : "secondary"}>
373
+ {isActive ? "Active" : "Stopped"}
374
+ </Badge>
375
+ </div>
376
+
377
+ <div className="flex items-center justify-between">
378
+ <span className="text-sm text-gray-600">Active Keys</span>
379
+ <Badge variant="outline">
380
+ {
381
+ Object.values(keyStates).filter((state) => state.pressed)
382
+ .length
383
+ }
384
+ </Badge>
385
+ </div>
386
+
387
+ <div className="space-y-2">
388
+ {isActive ? (
389
+ <Button
390
+ onClick={handleStop}
391
+ variant="destructive"
392
+ className="w-full"
393
+ >
394
+ ⏹️ Stop Teleoperation
395
+ </Button>
396
+ ) : (
397
+ <Button
398
+ onClick={handleStart}
399
+ disabled={!isConnected}
400
+ className="w-full"
401
+ >
402
+ ▶️ Start Teleoperation
403
+ </Button>
404
+ )}
405
+ </div>
406
+ </CardContent>
407
+ </Card>
408
+
409
+ {/* Virtual Keyboard */}
410
+ <Card>
411
+ <CardHeader>
412
+ <CardTitle>Virtual Keyboard</CardTitle>
413
+ </CardHeader>
414
+ <CardContent>
415
+ <VirtualKeyboard />
416
+ </CardContent>
417
+ </Card>
418
+
419
+ {/* Motor Status */}
420
+ <Card>
421
+ <CardHeader>
422
+ <CardTitle>Motor Positions</CardTitle>
423
+ </CardHeader>
424
+ <CardContent className="space-y-3">
425
+ {motorConfigs.map((motor, index) => {
426
+ return (
427
+ <div key={motor.name} className="space-y-1">
428
+ <div className="flex justify-between items-center">
429
+ <span className="text-sm font-medium">
430
+ {motor.name.replace("_", " ")}
431
+ </span>
432
+ <span className="text-xs text-gray-500">
433
+ {motor.currentPosition}
434
+ </span>
435
+ </div>
436
+ <input
437
+ type="range"
438
+ min={motor.minPosition}
439
+ max={motor.maxPosition}
440
+ value={motor.currentPosition}
441
+ disabled={!isActive}
442
+ className={`w-full h-2 rounded-lg appearance-none cursor-pointer bg-gray-200 slider-thumb ${
443
+ !isActive ? "opacity-50 cursor-not-allowed" : ""
444
+ }`}
445
+ style={{
446
+ background: isActive
447
+ ? `linear-gradient(to right, #3b82f6 0%, #3b82f6 ${
448
+ ((motor.currentPosition - motor.minPosition) /
449
+ (motor.maxPosition - motor.minPosition)) *
450
+ 100
451
+ }%, #e5e7eb ${
452
+ ((motor.currentPosition - motor.minPosition) /
453
+ (motor.maxPosition - motor.minPosition)) *
454
+ 100
455
+ }%, #e5e7eb 100%)`
456
+ : "#e5e7eb",
457
+ }}
458
+ onChange={async (e) => {
459
+ if (!isActive) return;
460
+ const newPosition = parseInt(e.target.value);
461
+ try {
462
+ await moveMotorToPosition(index, newPosition);
463
+ } catch (error) {
464
+ console.warn(
465
+ "Failed to move motor via slider:",
466
+ error
467
+ );
468
+ }
469
+ }}
470
+ />
471
+ <div className="flex justify-between text-xs text-gray-400">
472
+ <span>{motor.minPosition}</span>
473
+ <span>{motor.maxPosition}</span>
474
+ </div>
475
+ </div>
476
+ );
477
+ })}
478
+ </CardContent>
479
+ </Card>
480
+ </div>
481
+
482
+ {/* Help Card */}
483
+ <Card className="mt-6">
484
+ <CardHeader>
485
+ <CardTitle>Control Instructions</CardTitle>
486
+ </CardHeader>
487
+ <CardContent>
488
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 text-sm">
489
+ <div>
490
+ <h4 className="font-semibold mb-2">Arrow Keys</h4>
491
+ <ul className="space-y-1 text-gray-600">
492
+ <li>↑ ↓ Shoulder lift</li>
493
+ <li>← → Shoulder pan</li>
494
+ </ul>
495
+ </div>
496
+ <div>
497
+ <h4 className="font-semibold mb-2">WASD Keys</h4>
498
+ <ul className="space-y-1 text-gray-600">
499
+ <li>W S Elbow flex</li>
500
+ <li>A D Wrist flex</li>
501
+ </ul>
502
+ </div>
503
+ <div>
504
+ <h4 className="font-semibold mb-2">Other Keys</h4>
505
+ <ul className="space-y-1 text-gray-600">
506
+ <li>Q E Wrist roll</li>
507
+ <li>O Open gripper</li>
508
+ <li>C Close gripper</li>
509
+ </ul>
510
+ </div>
511
+ <div>
512
+ <h4 className="font-semibold mb-2 text-red-700">Emergency</h4>
513
+ <ul className="space-y-1 text-red-600">
514
+ <li>ESC Emergency stop</li>
515
+ </ul>
516
+ </div>
517
+ </div>
518
+ <div className="mt-4 p-3 bg-blue-50 rounded-lg">
519
+ <p className="text-sm text-blue-800">
520
+ 💡 <strong>Pro tip:</strong> Use your physical keyboard for
521
+ faster control, or click the virtual keys below. Hold keys down
522
+ for continuous movement.
523
+ </p>
524
+ </div>
525
+ </CardContent>
526
+ </Card>
527
+ </div>
528
+ </div>
529
+ );
530
+ }
examples/demo/components/ui/alert.tsx ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import { cva, type VariantProps } from "class-variance-authority";
3
+ import { cn } from "../../lib/utils";
4
+
5
+ const alertVariants = cva(
6
+ "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
7
+ {
8
+ variants: {
9
+ variant: {
10
+ default: "bg-background text-foreground",
11
+ destructive:
12
+ "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
13
+ },
14
+ },
15
+ defaultVariants: {
16
+ variant: "default",
17
+ },
18
+ }
19
+ );
20
+
21
+ const Alert = React.forwardRef<
22
+ HTMLDivElement,
23
+ React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
24
+ >(({ className, variant, ...props }, ref) => (
25
+ <div
26
+ ref={ref}
27
+ role="alert"
28
+ className={cn(alertVariants({ variant }), className)}
29
+ {...props}
30
+ />
31
+ ));
32
+ Alert.displayName = "Alert";
33
+
34
+ const AlertTitle = React.forwardRef<
35
+ HTMLParagraphElement,
36
+ React.HTMLAttributes<HTMLHeadingElement>
37
+ >(({ className, ...props }, ref) => (
38
+ <h5
39
+ ref={ref}
40
+ className={cn("mb-1 font-medium leading-none tracking-tight", className)}
41
+ {...props}
42
+ />
43
+ ));
44
+ AlertTitle.displayName = "AlertTitle";
45
+
46
+ const AlertDescription = React.forwardRef<
47
+ HTMLParagraphElement,
48
+ React.HTMLAttributes<HTMLParagraphElement>
49
+ >(({ className, ...props }, ref) => (
50
+ <div
51
+ ref={ref}
52
+ className={cn("text-sm [&_p]:leading-relaxed", className)}
53
+ {...props}
54
+ />
55
+ ));
56
+ AlertDescription.displayName = "AlertDescription";
57
+
58
+ export { Alert, AlertTitle, AlertDescription };
examples/demo/components/ui/badge.tsx ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import { cva, type VariantProps } from "class-variance-authority";
3
+ import { cn } from "../../lib/utils";
4
+
5
+ const badgeVariants = cva(
6
+ "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
7
+ {
8
+ variants: {
9
+ variant: {
10
+ default:
11
+ "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
12
+ secondary:
13
+ "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
14
+ destructive:
15
+ "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
16
+ outline: "text-foreground",
17
+ },
18
+ },
19
+ defaultVariants: {
20
+ variant: "default",
21
+ },
22
+ }
23
+ );
24
+
25
+ export interface BadgeProps
26
+ extends React.HTMLAttributes<HTMLDivElement>,
27
+ VariantProps<typeof badgeVariants> {}
28
+
29
+ function Badge({ className, variant, ...props }: BadgeProps) {
30
+ return (
31
+ <div className={cn(badgeVariants({ variant }), className)} {...props} />
32
+ );
33
+ }
34
+
35
+ export { Badge, badgeVariants };
examples/demo/components/ui/button.tsx ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import { cva, type VariantProps } from "class-variance-authority";
3
+ import { cn } from "../../lib/utils";
4
+
5
+ const buttonVariants = cva(
6
+ "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
7
+ {
8
+ variants: {
9
+ variant: {
10
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
11
+ destructive:
12
+ "bg-destructive text-destructive-foreground hover:bg-destructive/90",
13
+ outline:
14
+ "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
15
+ secondary:
16
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
17
+ ghost: "hover:bg-accent hover:text-accent-foreground",
18
+ link: "text-primary underline-offset-4 hover:underline",
19
+ },
20
+ size: {
21
+ default: "h-10 px-4 py-2",
22
+ sm: "h-9 rounded-md px-3",
23
+ lg: "h-11 rounded-md px-8",
24
+ icon: "h-10 w-10",
25
+ },
26
+ },
27
+ defaultVariants: {
28
+ variant: "default",
29
+ size: "default",
30
+ },
31
+ }
32
+ );
33
+
34
+ export interface ButtonProps
35
+ extends React.ButtonHTMLAttributes<HTMLButtonElement>,
36
+ VariantProps<typeof buttonVariants> {
37
+ asChild?: boolean;
38
+ }
39
+
40
+ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
41
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
42
+ return (
43
+ <button
44
+ className={cn(buttonVariants({ variant, size, className }))}
45
+ ref={ref}
46
+ {...props}
47
+ />
48
+ );
49
+ }
50
+ );
51
+ Button.displayName = "Button";
52
+
53
+ export { Button, buttonVariants };
examples/demo/components/ui/card.tsx ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import { cn } from "../../lib/utils";
3
+
4
+ const Card = React.forwardRef<
5
+ HTMLDivElement,
6
+ React.HTMLAttributes<HTMLDivElement>
7
+ >(({ className, ...props }, ref) => (
8
+ <div
9
+ ref={ref}
10
+ className={cn(
11
+ "rounded-lg border bg-card text-card-foreground shadow-sm",
12
+ className
13
+ )}
14
+ {...props}
15
+ />
16
+ ));
17
+ Card.displayName = "Card";
18
+
19
+ const CardHeader = React.forwardRef<
20
+ HTMLDivElement,
21
+ React.HTMLAttributes<HTMLDivElement>
22
+ >(({ className, ...props }, ref) => (
23
+ <div
24
+ ref={ref}
25
+ className={cn("flex flex-col space-y-1.5 p-6", className)}
26
+ {...props}
27
+ />
28
+ ));
29
+ CardHeader.displayName = "CardHeader";
30
+
31
+ const CardTitle = React.forwardRef<
32
+ HTMLParagraphElement,
33
+ React.HTMLAttributes<HTMLHeadingElement>
34
+ >(({ className, ...props }, ref) => (
35
+ <h3
36
+ ref={ref}
37
+ className={cn(
38
+ "text-2xl font-semibold leading-none tracking-tight",
39
+ className
40
+ )}
41
+ {...props}
42
+ />
43
+ ));
44
+ CardTitle.displayName = "CardTitle";
45
+
46
+ const CardDescription = React.forwardRef<
47
+ HTMLParagraphElement,
48
+ React.HTMLAttributes<HTMLParagraphElement>
49
+ >(({ className, ...props }, ref) => (
50
+ <p
51
+ ref={ref}
52
+ className={cn("text-sm text-muted-foreground", className)}
53
+ {...props}
54
+ />
55
+ ));
56
+ CardDescription.displayName = "CardDescription";
57
+
58
+ const CardContent = React.forwardRef<
59
+ HTMLDivElement,
60
+ React.HTMLAttributes<HTMLDivElement>
61
+ >(({ className, ...props }, ref) => (
62
+ <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
63
+ ));
64
+ CardContent.displayName = "CardContent";
65
+
66
+ const CardFooter = React.forwardRef<
67
+ HTMLDivElement,
68
+ React.HTMLAttributes<HTMLDivElement>
69
+ >(({ className, ...props }, ref) => (
70
+ <div
71
+ ref={ref}
72
+ className={cn("flex items-center p-6 pt-0", className)}
73
+ {...props}
74
+ />
75
+ ));
76
+ CardFooter.displayName = "CardFooter";
77
+
78
+ export {
79
+ Card,
80
+ CardHeader,
81
+ CardFooter,
82
+ CardTitle,
83
+ CardDescription,
84
+ CardContent,
85
+ };
examples/demo/components/ui/dialog.tsx ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import * as DialogPrimitive from "@radix-ui/react-dialog";
3
+ import { X } from "lucide-react";
4
+
5
+ import { cn } from "../../lib/utils";
6
+
7
+ const Dialog = DialogPrimitive.Root;
8
+
9
+ const DialogTrigger = DialogPrimitive.Trigger;
10
+
11
+ const DialogPortal = DialogPrimitive.Portal;
12
+
13
+ const DialogClose = DialogPrimitive.Close;
14
+
15
+ const DialogOverlay = React.forwardRef<
16
+ React.ElementRef<typeof DialogPrimitive.Overlay>,
17
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
18
+ >(({ className, ...props }, ref) => (
19
+ <DialogPrimitive.Overlay
20
+ ref={ref}
21
+ className={cn(
22
+ "fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
23
+ className
24
+ )}
25
+ {...props}
26
+ />
27
+ ));
28
+ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
29
+
30
+ const DialogContent = React.forwardRef<
31
+ React.ElementRef<typeof DialogPrimitive.Content>,
32
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
33
+ >(({ className, children, ...props }, ref) => (
34
+ <DialogPortal>
35
+ <DialogOverlay />
36
+ <DialogPrimitive.Content
37
+ ref={ref}
38
+ className={cn(
39
+ "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
40
+ className
41
+ )}
42
+ {...props}
43
+ >
44
+ {children}
45
+ <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
46
+ <X className="h-4 w-4" />
47
+ <span className="sr-only">Close</span>
48
+ </DialogPrimitive.Close>
49
+ </DialogPrimitive.Content>
50
+ </DialogPortal>
51
+ ));
52
+ DialogContent.displayName = DialogPrimitive.Content.displayName;
53
+
54
+ const DialogHeader = ({
55
+ className,
56
+ ...props
57
+ }: React.HTMLAttributes<HTMLDivElement>) => (
58
+ <div
59
+ className={cn(
60
+ "flex flex-col space-y-1.5 text-center sm:text-left",
61
+ className
62
+ )}
63
+ {...props}
64
+ />
65
+ );
66
+ DialogHeader.displayName = "DialogHeader";
67
+
68
+ const DialogFooter = ({
69
+ className,
70
+ ...props
71
+ }: React.HTMLAttributes<HTMLDivElement>) => (
72
+ <div
73
+ className={cn(
74
+ "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
75
+ className
76
+ )}
77
+ {...props}
78
+ />
79
+ );
80
+ DialogFooter.displayName = "DialogFooter";
81
+
82
+ const DialogTitle = React.forwardRef<
83
+ React.ElementRef<typeof DialogPrimitive.Title>,
84
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
85
+ >(({ className, ...props }, ref) => (
86
+ <DialogPrimitive.Title
87
+ ref={ref}
88
+ className={cn(
89
+ "text-lg font-semibold leading-none tracking-tight",
90
+ className
91
+ )}
92
+ {...props}
93
+ />
94
+ ));
95
+ DialogTitle.displayName = DialogPrimitive.Title.displayName;
96
+
97
+ const DialogDescription = React.forwardRef<
98
+ React.ElementRef<typeof DialogPrimitive.Description>,
99
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
100
+ >(({ className, ...props }, ref) => (
101
+ <DialogPrimitive.Description
102
+ ref={ref}
103
+ className={cn("text-sm text-muted-foreground", className)}
104
+ {...props}
105
+ />
106
+ ));
107
+ DialogDescription.displayName = DialogPrimitive.Description.displayName;
108
+
109
+ export {
110
+ Dialog,
111
+ DialogPortal,
112
+ DialogOverlay,
113
+ DialogClose,
114
+ DialogContent,
115
+ DialogDescription,
116
+ DialogFooter,
117
+ DialogHeader,
118
+ DialogTitle,
119
+ DialogTrigger,
120
+ };
examples/demo/components/ui/progress.tsx ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import * as ProgressPrimitive from "@radix-ui/react-progress";
3
+
4
+ import { cn } from "../../lib/utils";
5
+
6
+ const Progress = React.forwardRef<
7
+ React.ElementRef<typeof ProgressPrimitive.Root>,
8
+ React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
9
+ >(({ className, value, ...props }, ref) => (
10
+ <ProgressPrimitive.Root
11
+ ref={ref}
12
+ className={cn(
13
+ "relative h-4 w-full overflow-hidden rounded-full bg-secondary",
14
+ className
15
+ )}
16
+ {...props}
17
+ >
18
+ <ProgressPrimitive.Indicator
19
+ className="h-full w-full flex-1 bg-primary transition-all"
20
+ style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
21
+ />
22
+ </ProgressPrimitive.Root>
23
+ ));
24
+ Progress.displayName = ProgressPrimitive.Root.displayName;
25
+
26
+ export { Progress };
examples/demo/index.css ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ @layer base {
6
+ * {
7
+ @apply border-border;
8
+ }
9
+ body {
10
+ @apply bg-background text-foreground;
11
+ }
12
+ }
examples/demo/lib/unified-storage.ts ADDED
@@ -0,0 +1,325 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Unified storage system for robot data
2
+ // Consolidates robot config, calibration data, and metadata under one key per device
3
+
4
+ export interface UnifiedRobotData {
5
+ device_info: {
6
+ serialNumber: string;
7
+ robotType: "so100_follower" | "so100_leader";
8
+ robotId: string;
9
+ usbMetadata?: any;
10
+ lastUpdated: string;
11
+ };
12
+ calibration?: {
13
+ // Motor calibration data (from lerobot_calibration_* keys)
14
+ shoulder_pan?: {
15
+ id: number;
16
+ drive_mode: number;
17
+ homing_offset: number;
18
+ range_min: number;
19
+ range_max: number;
20
+ };
21
+ shoulder_lift?: {
22
+ id: number;
23
+ drive_mode: number;
24
+ homing_offset: number;
25
+ range_min: number;
26
+ range_max: number;
27
+ };
28
+ elbow_flex?: {
29
+ id: number;
30
+ drive_mode: number;
31
+ homing_offset: number;
32
+ range_min: number;
33
+ range_max: number;
34
+ };
35
+ wrist_flex?: {
36
+ id: number;
37
+ drive_mode: number;
38
+ homing_offset: number;
39
+ range_min: number;
40
+ range_max: number;
41
+ };
42
+ wrist_roll?: {
43
+ id: number;
44
+ drive_mode: number;
45
+ homing_offset: number;
46
+ range_min: number;
47
+ range_max: number;
48
+ };
49
+ gripper?: {
50
+ id: number;
51
+ drive_mode: number;
52
+ homing_offset: number;
53
+ range_min: number;
54
+ range_max: number;
55
+ };
56
+
57
+ // Calibration metadata (from lerobot-calibration-* keys)
58
+ metadata: {
59
+ timestamp: string;
60
+ readCount: number;
61
+ platform: string;
62
+ api: string;
63
+ device_type: string;
64
+ device_id: string;
65
+ calibrated_at: string;
66
+ };
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Get unified storage key for a robot by serial number
72
+ */
73
+ export function getUnifiedKey(serialNumber: string): string {
74
+ return `lerobotjs-${serialNumber}`;
75
+ }
76
+
77
+ /**
78
+ * Migrate data from old storage keys to unified format
79
+ * Safely combines data from three sources:
80
+ * 1. lerobot-robot-{serialNumber} - robot config
81
+ * 2. lerobot-calibration-{serialNumber} - calibration metadata
82
+ * 3. lerobot_calibration_{robotType}_{robotId} - actual calibration data
83
+ */
84
+ export function migrateToUnifiedStorage(
85
+ serialNumber: string
86
+ ): UnifiedRobotData | null {
87
+ try {
88
+ const unifiedKey = getUnifiedKey(serialNumber);
89
+
90
+ // Check if already migrated
91
+ const existing = localStorage.getItem(unifiedKey);
92
+ if (existing) {
93
+ console.log(`✅ Data already unified for ${serialNumber}`);
94
+ return JSON.parse(existing);
95
+ }
96
+
97
+ console.log(`🔄 Migrating data for serial number: ${serialNumber}`);
98
+
99
+ // 1. Get robot configuration
100
+ const robotConfigKey = `lerobot-robot-${serialNumber}`;
101
+ const robotConfigRaw = localStorage.getItem(robotConfigKey);
102
+
103
+ if (!robotConfigRaw) {
104
+ return null;
105
+ }
106
+
107
+ const robotConfig = JSON.parse(robotConfigRaw);
108
+ console.log(`📋 Found robot config:`, robotConfig);
109
+
110
+ // 2. Get calibration metadata
111
+ const calibrationMetaKey = `lerobot-calibration-${serialNumber}`;
112
+ const calibrationMetaRaw = localStorage.getItem(calibrationMetaKey);
113
+ const calibrationMeta = calibrationMetaRaw
114
+ ? JSON.parse(calibrationMetaRaw)
115
+ : null;
116
+ console.log(`📊 Found calibration metadata:`, calibrationMeta);
117
+
118
+ // 3. Get actual calibration data (using robotType and robotId from config)
119
+ const calibrationDataKey = `lerobot_calibration_${robotConfig.robotType}_${robotConfig.robotId}`;
120
+ const calibrationDataRaw = localStorage.getItem(calibrationDataKey);
121
+ const calibrationData = calibrationDataRaw
122
+ ? JSON.parse(calibrationDataRaw)
123
+ : null;
124
+ console.log(`🔧 Found calibration data:`, calibrationData);
125
+
126
+ // 4. Build unified structure
127
+ const unifiedData: UnifiedRobotData = {
128
+ device_info: {
129
+ serialNumber: robotConfig.serialNumber || serialNumber,
130
+ robotType: robotConfig.robotType,
131
+ robotId: robotConfig.robotId,
132
+ lastUpdated: robotConfig.lastUpdated || new Date().toISOString(),
133
+ },
134
+ };
135
+
136
+ // Add calibration if available
137
+ if (calibrationData && calibrationMeta) {
138
+ const motors: any = {};
139
+
140
+ // Copy motor data (excluding metadata fields)
141
+ Object.keys(calibrationData).forEach((key) => {
142
+ if (
143
+ ![
144
+ "device_type",
145
+ "device_id",
146
+ "calibrated_at",
147
+ "platform",
148
+ "api",
149
+ ].includes(key)
150
+ ) {
151
+ motors[key] = calibrationData[key];
152
+ }
153
+ });
154
+
155
+ unifiedData.calibration = {
156
+ ...motors,
157
+ metadata: {
158
+ timestamp: calibrationMeta.timestamp || calibrationData.calibrated_at,
159
+ readCount: calibrationMeta.readCount || 0,
160
+ platform: calibrationData.platform || "web",
161
+ api: calibrationData.api || "Web Serial API",
162
+ device_type: calibrationData.device_type || robotConfig.robotType,
163
+ device_id: calibrationData.device_id || robotConfig.robotId,
164
+ calibrated_at:
165
+ calibrationData.calibrated_at || calibrationMeta.timestamp,
166
+ },
167
+ };
168
+ }
169
+
170
+ // 5. Save unified data
171
+ localStorage.setItem(unifiedKey, JSON.stringify(unifiedData));
172
+ console.log(`✅ Successfully unified data for ${serialNumber}`);
173
+ console.log(`📦 Unified data:`, unifiedData);
174
+
175
+ // 6. Clean up old keys (optional - keep for now for safety)
176
+ // localStorage.removeItem(robotConfigKey);
177
+ // localStorage.removeItem(calibrationMetaKey);
178
+ // localStorage.removeItem(calibrationDataKey);
179
+
180
+ return unifiedData;
181
+ } catch (error) {
182
+ console.error(`❌ Failed to migrate data for ${serialNumber}:`, error);
183
+ return null;
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Get unified robot data
189
+ */
190
+ export function getUnifiedRobotData(
191
+ serialNumber: string
192
+ ): UnifiedRobotData | null {
193
+ const unifiedKey = getUnifiedKey(serialNumber);
194
+
195
+ // Try to get existing unified data
196
+ const existing = localStorage.getItem(unifiedKey);
197
+ if (existing) {
198
+ try {
199
+ return JSON.parse(existing);
200
+ } catch (error) {
201
+ console.warn(`Failed to parse unified data for ${serialNumber}:`, error);
202
+ }
203
+ }
204
+
205
+ return null;
206
+ }
207
+
208
+ /**
209
+ * Save robot configuration to unified storage
210
+ */
211
+ export function saveRobotConfig(
212
+ serialNumber: string,
213
+ robotType: "so100_follower" | "so100_leader",
214
+ robotId: string,
215
+ usbMetadata?: any
216
+ ): void {
217
+ const unifiedKey = getUnifiedKey(serialNumber);
218
+ const existing =
219
+ getUnifiedRobotData(serialNumber) || ({} as UnifiedRobotData);
220
+
221
+ existing.device_info = {
222
+ serialNumber,
223
+ robotType,
224
+ robotId,
225
+ usbMetadata,
226
+ lastUpdated: new Date().toISOString(),
227
+ };
228
+
229
+ localStorage.setItem(unifiedKey, JSON.stringify(existing));
230
+ console.log(`💾 Saved robot config for ${serialNumber}`);
231
+ }
232
+
233
+ /**
234
+ * Save calibration data to unified storage
235
+ */
236
+ export function saveCalibrationData(
237
+ serialNumber: string,
238
+ calibrationData: any,
239
+ metadata: { timestamp: string; readCount: number }
240
+ ): void {
241
+ const unifiedKey = getUnifiedKey(serialNumber);
242
+ const existing =
243
+ getUnifiedRobotData(serialNumber) || ({} as UnifiedRobotData);
244
+
245
+ // Ensure device_info exists
246
+ if (!existing.device_info) {
247
+ console.warn(
248
+ `No device info found for ${serialNumber}, cannot save calibration`
249
+ );
250
+ return;
251
+ }
252
+
253
+ // Extract motor data (exclude metadata fields)
254
+ const motors: any = {};
255
+ Object.keys(calibrationData).forEach((key) => {
256
+ if (
257
+ ![
258
+ "device_type",
259
+ "device_id",
260
+ "calibrated_at",
261
+ "platform",
262
+ "api",
263
+ ].includes(key)
264
+ ) {
265
+ motors[key] = calibrationData[key];
266
+ }
267
+ });
268
+
269
+ existing.calibration = {
270
+ ...motors,
271
+ metadata: {
272
+ timestamp: metadata.timestamp,
273
+ readCount: metadata.readCount,
274
+ platform: calibrationData.platform || "web",
275
+ api: calibrationData.api || "Web Serial API",
276
+ device_type:
277
+ calibrationData.device_type || existing.device_info.robotType,
278
+ device_id: calibrationData.device_id || existing.device_info.robotId,
279
+ calibrated_at: calibrationData.calibrated_at || metadata.timestamp,
280
+ },
281
+ };
282
+
283
+ localStorage.setItem(unifiedKey, JSON.stringify(existing));
284
+ console.log(`🔧 Saved calibration data for ${serialNumber}`);
285
+ }
286
+
287
+ /**
288
+ * Check if robot is calibrated
289
+ */
290
+ export function isRobotCalibrated(serialNumber: string): boolean {
291
+ const data = getUnifiedRobotData(serialNumber);
292
+ return !!data?.calibration?.metadata?.timestamp;
293
+ }
294
+
295
+ /**
296
+ * Get calibration status for dashboard
297
+ */
298
+ export function getCalibrationStatus(
299
+ serialNumber: string
300
+ ): { timestamp: string; readCount: number } | null {
301
+ const data = getUnifiedRobotData(serialNumber);
302
+ if (data?.calibration?.metadata) {
303
+ return {
304
+ timestamp: data.calibration.metadata.timestamp,
305
+ readCount: data.calibration.metadata.readCount,
306
+ };
307
+ }
308
+ return null;
309
+ }
310
+
311
+ /**
312
+ * Get robot configuration
313
+ */
314
+ export function getRobotConfig(
315
+ serialNumber: string
316
+ ): { robotType: string; robotId: string } | null {
317
+ const data = getUnifiedRobotData(serialNumber);
318
+ if (data?.device_info) {
319
+ return {
320
+ robotType: data.device_info.robotType,
321
+ robotId: data.device_info.robotId,
322
+ };
323
+ }
324
+ return null;
325
+ }
examples/demo/lib/utils.ts ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ import { type ClassValue, clsx } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
examples/demo/main.tsx ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ import ReactDOM from "react-dom/client";
2
+ import { App } from "./App";
3
+ import "./index.css";
4
+
5
+ ReactDOM.createRoot(document.getElementById("root")!).render(<App />);
examples/demo/pages/Home.tsx ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import { Button } from "../components/ui/button";
3
+ import { Alert, AlertDescription } from "../components/ui/alert";
4
+ import { PortManager } from "../components/PortManager";
5
+ import { CalibrationPanel } from "../components/CalibrationPanel";
6
+ import { TeleoperationPanel } from "../components/TeleoperationPanel";
7
+ import { isWebSerialSupported } from "@lerobot/web";
8
+ import type { RobotConnection } from "@lerobot/web";
9
+
10
+ interface HomeProps {
11
+ connectedRobots: RobotConnection[];
12
+ onConnectedRobotsChange: (robots: RobotConnection[]) => void;
13
+ }
14
+
15
+ export function Home({ connectedRobots, onConnectedRobotsChange }: HomeProps) {
16
+ const [calibratingRobot, setCalibratingRobot] =
17
+ useState<RobotConnection | null>(null);
18
+ const [teleoperatingRobot, setTeleoperatingRobot] =
19
+ useState<RobotConnection | null>(null);
20
+ const isSupported = isWebSerialSupported();
21
+
22
+ const handleCalibrate = (port: SerialPort) => {
23
+ // Find the robot from connectedRobots
24
+ const robot = connectedRobots.find((r) => r.port === port);
25
+ if (robot) {
26
+ setCalibratingRobot(robot);
27
+ }
28
+ };
29
+
30
+ const handleTeleoperate = (robot: RobotConnection) => {
31
+ setTeleoperatingRobot(robot);
32
+ };
33
+
34
+ const handleFinishCalibration = () => {
35
+ setCalibratingRobot(null);
36
+ };
37
+
38
+ const handleFinishTeleoperation = () => {
39
+ setTeleoperatingRobot(null);
40
+ };
41
+
42
+ return (
43
+ <div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
44
+ <div className="container mx-auto px-6 py-12">
45
+ {/* Header */}
46
+ <div className="text-center mb-12">
47
+ <h1 className="text-4xl font-bold text-gray-900 mb-4">
48
+ 🤖 LeRobot.js
49
+ </h1>
50
+ <p className="text-xl text-gray-600 mb-8">
51
+ Robotics for the web and node
52
+ </p>
53
+
54
+ {!isSupported && (
55
+ <Alert variant="destructive" className="max-w-2xl mx-auto mb-8">
56
+ <AlertDescription>
57
+ Web Serial API is not supported in this browser. Please use
58
+ Chrome, Edge, or another Chromium-based browser to use this
59
+ demo.
60
+ </AlertDescription>
61
+ </Alert>
62
+ )}
63
+ </div>
64
+
65
+ {/* Main Content */}
66
+ {calibratingRobot ? (
67
+ <div className="max-w-6xl mx-auto">
68
+ <div className="mb-4">
69
+ <Button
70
+ variant="outline"
71
+ onClick={() => setCalibratingRobot(null)}
72
+ >
73
+ ← Back to Dashboard
74
+ </Button>
75
+ </div>
76
+ <CalibrationPanel
77
+ robot={calibratingRobot}
78
+ onFinish={handleFinishCalibration}
79
+ />
80
+ </div>
81
+ ) : teleoperatingRobot ? (
82
+ <TeleoperationPanel
83
+ robot={teleoperatingRobot}
84
+ onClose={handleFinishTeleoperation}
85
+ />
86
+ ) : (
87
+ <div className="max-w-6xl mx-auto">
88
+ <PortManager
89
+ onCalibrate={handleCalibrate}
90
+ onTeleoperate={handleTeleoperate}
91
+ connectedRobots={connectedRobots}
92
+ onConnectedRobotsChange={onConnectedRobotsChange}
93
+ />
94
+ </div>
95
+ )}
96
+ </div>
97
+ </div>
98
+ );
99
+ }
src/lerobot/node/calibrate.ts ADDED
@@ -0,0 +1,248 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 {
16
+ initializeDeviceCommunication,
17
+ readMotorPositions,
18
+ performInteractiveCalibration,
19
+ setMotorLimits,
20
+ verifyCalibration,
21
+ type CalibrationResults,
22
+ } from "./common/calibration.js";
23
+ import { getSO100Config } from "./common/so100_config.js";
24
+
25
+ /**
26
+ * Main calibrate function
27
+ * Mirrors Python lerobot calibrate.py calibrate() function
28
+ * Uses shared calibration procedures instead of device-specific implementations
29
+ */
30
+ export async function calibrate(config: CalibrateConfig): Promise<void> {
31
+ // Validate configuration - exactly one device must be specified
32
+ if (Boolean(config.robot) === Boolean(config.teleop)) {
33
+ throw new Error("Choose either a robot or a teleop.");
34
+ }
35
+
36
+ const deviceConfig = config.robot || config.teleop!;
37
+
38
+ let device;
39
+ let calibrationResults: CalibrationResults;
40
+
41
+ try {
42
+ // Create device for connection management only
43
+ if (config.robot) {
44
+ switch (config.robot.type) {
45
+ case "so100_follower":
46
+ device = createSO100Follower(config.robot);
47
+ break;
48
+ default:
49
+ throw new Error(`Unsupported robot type: ${config.robot.type}`);
50
+ }
51
+ } else if (config.teleop) {
52
+ switch (config.teleop.type) {
53
+ case "so100_leader":
54
+ device = createSO100Leader(config.teleop);
55
+ break;
56
+ default:
57
+ throw new Error(
58
+ `Unsupported teleoperator type: ${config.teleop.type}`
59
+ );
60
+ }
61
+ }
62
+
63
+ if (!device) {
64
+ throw new Error("Failed to create device");
65
+ }
66
+
67
+ // Connect to device (silent unless error)
68
+ await device.connect(false); // calibrate=False like Python
69
+
70
+ // Get SO-100 calibration configuration
71
+ const so100Config = getSO100Config(
72
+ deviceConfig.type as "so100_follower" | "so100_leader",
73
+ (device as any).port
74
+ );
75
+
76
+ // Perform shared calibration procedures (silent unless error)
77
+ await initializeDeviceCommunication(so100Config);
78
+ await setMotorLimits(so100Config);
79
+
80
+ // Interactive calibration with live updates - THE MAIN PART
81
+ calibrationResults = await performInteractiveCalibration(so100Config);
82
+
83
+ // Save and cleanup (silent unless error)
84
+ await verifyCalibration(so100Config);
85
+ await (device as any).saveCalibration(calibrationResults);
86
+ await device.disconnect();
87
+ } catch (error) {
88
+ // Ensure we disconnect even if there's an error
89
+ if (device) {
90
+ try {
91
+ await device.disconnect();
92
+ } catch (disconnectError) {
93
+ console.warn("Warning: Failed to disconnect properly");
94
+ }
95
+ }
96
+ throw error;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Parse command line arguments in Python argparse style
102
+ * Handles --robot.type=so100_follower --robot.port=COM4 format
103
+ */
104
+ export function parseArgs(args: string[]): CalibrateConfig {
105
+ const config: CalibrateConfig = {};
106
+
107
+ for (const arg of args) {
108
+ if (arg.startsWith("--robot.")) {
109
+ if (!config.robot) {
110
+ config.robot = { type: "so100_follower", port: "" };
111
+ }
112
+
113
+ const [key, value] = arg.substring(8).split("=");
114
+ switch (key) {
115
+ case "type":
116
+ if (value !== "so100_follower") {
117
+ throw new Error(`Unsupported robot type: ${value}`);
118
+ }
119
+ config.robot.type = value as "so100_follower";
120
+ break;
121
+ case "port":
122
+ config.robot.port = value;
123
+ break;
124
+ case "id":
125
+ config.robot.id = value;
126
+ break;
127
+ case "disable_torque_on_disconnect":
128
+ config.robot.disable_torque_on_disconnect = value === "true";
129
+ break;
130
+ case "max_relative_target":
131
+ config.robot.max_relative_target = value ? parseInt(value) : null;
132
+ break;
133
+ case "use_degrees":
134
+ config.robot.use_degrees = value === "true";
135
+ break;
136
+ default:
137
+ throw new Error(`Unknown robot parameter: ${key}`);
138
+ }
139
+ } else if (arg.startsWith("--teleop.")) {
140
+ if (!config.teleop) {
141
+ config.teleop = { type: "so100_leader", port: "" };
142
+ }
143
+
144
+ const [key, value] = arg.substring(9).split("=");
145
+ switch (key) {
146
+ case "type":
147
+ if (value !== "so100_leader") {
148
+ throw new Error(`Unsupported teleoperator type: ${value}`);
149
+ }
150
+ config.teleop.type = value as "so100_leader";
151
+ break;
152
+ case "port":
153
+ config.teleop.port = value;
154
+ break;
155
+ case "id":
156
+ config.teleop.id = value;
157
+ break;
158
+ default:
159
+ throw new Error(`Unknown teleoperator parameter: ${key}`);
160
+ }
161
+ } else if (arg === "--help" || arg === "-h") {
162
+ showUsage();
163
+ process.exit(0);
164
+ } else if (!arg.startsWith("--")) {
165
+ // Skip non-option arguments
166
+ continue;
167
+ } else {
168
+ throw new Error(`Unknown argument: ${arg}`);
169
+ }
170
+ }
171
+
172
+ // Validate required fields
173
+ if (config.robot && !config.robot.port) {
174
+ throw new Error("Robot port is required (--robot.port=PORT)");
175
+ }
176
+ if (config.teleop && !config.teleop.port) {
177
+ throw new Error("Teleoperator port is required (--teleop.port=PORT)");
178
+ }
179
+
180
+ return config;
181
+ }
182
+
183
+ /**
184
+ * Show usage information matching Python argparse output
185
+ */
186
+ function showUsage(): void {
187
+ console.log("Usage: lerobot calibrate [options]");
188
+ console.log("");
189
+ console.log("Recalibrate your device (robot or teleoperator)");
190
+ console.log("");
191
+ console.log("Options:");
192
+ console.log(" --robot.type=TYPE Robot type (so100_follower)");
193
+ console.log(
194
+ " --robot.port=PORT Robot serial port (e.g., COM4, /dev/ttyUSB0)"
195
+ );
196
+ console.log(" --robot.id=ID Robot identifier");
197
+ console.log(" --teleop.type=TYPE Teleoperator type (so100_leader)");
198
+ console.log(" --teleop.port=PORT Teleoperator serial port");
199
+ console.log(" --teleop.id=ID Teleoperator identifier");
200
+ console.log(" -h, --help Show this help message");
201
+ console.log("");
202
+ console.log("Examples:");
203
+ console.log(
204
+ " lerobot calibrate --robot.type=so100_follower --robot.port=COM4 --robot.id=my_follower_arm"
205
+ );
206
+ console.log(
207
+ " lerobot calibrate --teleop.type=so100_leader --teleop.port=COM3 --teleop.id=my_leader_arm"
208
+ );
209
+ console.log("");
210
+ console.log("Use 'lerobot find-port' to discover available ports.");
211
+ }
212
+
213
+ /**
214
+ * CLI entry point when called directly
215
+ * Mirrors Python's if __name__ == "__main__": pattern
216
+ */
217
+ export async function main(args: string[]): Promise<void> {
218
+ try {
219
+ if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
220
+ showUsage();
221
+ return;
222
+ }
223
+
224
+ const config = parseArgs(args);
225
+ await calibrate(config);
226
+ } catch (error) {
227
+ if (error instanceof Error) {
228
+ console.error("Error:", error.message);
229
+ } else {
230
+ console.error("Error:", error);
231
+ }
232
+
233
+ console.error("");
234
+ console.error("Please verify:");
235
+ console.error("1. The device is connected to the specified port");
236
+ console.error("2. No other application is using the port");
237
+ console.error("3. You have permission to access the port");
238
+ console.error("");
239
+ console.error("Use 'lerobot find-port' to discover available ports.");
240
+
241
+ process.exit(1);
242
+ }
243
+ }
244
+
245
+ if (import.meta.url === `file://${process.argv[1]}`) {
246
+ const args = process.argv.slice(2);
247
+ main(args);
248
+ }
src/lerobot/node/common/calibration.ts ADDED
@@ -0,0 +1,368 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Shared calibration procedures for SO-100 devices (both leader and follower)
3
+ * Mirrors Python lerobot calibrate.py common functionality
4
+ *
5
+ * Both SO-100 leader and follower use the same STS3215 servos and calibration procedures,
6
+ * only differing in configuration parameters (drive modes, limits, etc.)
7
+ */
8
+
9
+ import * as readline from "readline";
10
+ 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)
45
+ */
46
+ export async function initializeDeviceCommunication(
47
+ config: SO100CalibrationConfig
48
+ ): Promise<void> {
49
+ try {
50
+ // Test ping to servo ID 1 (same protocol for all SO-100 devices)
51
+ const pingPacket = Buffer.from([0xff, 0xff, 0x01, 0x02, 0x01, 0xfb]);
52
+
53
+ if (!config.port || !config.port.isOpen) {
54
+ throw new Error("Serial port not open");
55
+ }
56
+
57
+ await new Promise<void>((resolve, reject) => {
58
+ config.port.write(pingPacket, (error) => {
59
+ if (error) {
60
+ reject(new Error(`Failed to send ping: ${error.message}`));
61
+ } else {
62
+ resolve();
63
+ }
64
+ });
65
+ });
66
+
67
+ try {
68
+ await readData(config.port, 1000);
69
+ } catch (error) {
70
+ // Silent - no response expected for basic test
71
+ }
72
+ } catch (error) {
73
+ throw new Error(
74
+ `Serial communication test failed: ${
75
+ error instanceof Error ? error.message : error
76
+ }`
77
+ );
78
+ }
79
+ }
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) {
112
+ throw new Error("Serial port not open");
113
+ }
114
+
115
+ await new Promise<void>((resolve, reject) => {
116
+ config.port.write(packet, (error) => {
117
+ if (error) {
118
+ reject(new Error(`Failed to send read packet: ${error.message}`));
119
+ } else {
120
+ resolve();
121
+ }
122
+ });
123
+ });
124
+
125
+ try {
126
+ const response = await readData(config.port, 100); // Faster timeout for 30Hz performance
127
+ if (response.length >= 7) {
128
+ const id = response[2];
129
+ const error = response[4];
130
+ if (id === motorId && error === 0) {
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
147
+ await new Promise((resolve) => setTimeout(resolve, 2));
148
+ }
149
+
150
+ return motorPositions;
151
+ }
152
+
153
+ /**
154
+ * Interactive calibration procedure
155
+ * Same flow for both leader and follower, just different configurations
156
+ */
157
+ 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
+ );
165
+
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
+ }
184
+
185
+ /**
186
+ * Set motor limits (device-specific)
187
+ */
188
+ export async function setMotorLimits(
189
+ config: SO100CalibrationConfig
190
+ ): Promise<void> {
191
+ // Silent unless error - motor limits configured internally
192
+ }
193
+
194
+ /**
195
+ * Verify calibration was successful
196
+ */
197
+ export async function verifyCalibration(
198
+ config: SO100CalibrationConfig
199
+ ): Promise<void> {
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()
226
+ */
227
+ 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
+ );
235
+ console.log(
236
+ "Positions will be recorded continuously. Press ENTER to stop...\n"
237
+ );
238
+
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;
252
+ let readCount = 0;
253
+
254
+ // Set up readline to detect Enter key
255
+ const rl = readline.createInterface({
256
+ input: process.stdin,
257
+ output: process.stdout,
258
+ });
259
+
260
+ rl.on("line", () => {
261
+ recording = false;
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 {
271
+ const positions = await readMotorPositions(config); // Always quiet during live recording
272
+ readCount++;
273
+
274
+ // Update min/max ranges
275
+ for (let i = 0; i < config.motorNames.length; i++) {
276
+ const motorName = config.motorNames[i];
277
+ const position = positions[i];
278
+
279
+ if (position < rangeMins[motorName]) {
280
+ rangeMins[motorName] = position;
281
+ }
282
+ if (position > rangeMaxes[motorName]) {
283
+ rangeMaxes[motorName] = position;
284
+ }
285
+ }
286
+
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
+
295
+ for (let i = 0; i < config.motorNames.length; i++) {
296
+ const motorName = config.motorNames[i];
297
+ const current = positions[i];
298
+ const min = rangeMins[motorName];
299
+ const max = rangeMaxes[motorName];
300
+ const range = max - min;
301
+
302
+ liveTable += `${motorName.padEnd(15)} ${current
303
+ .toString()
304
+ .padStart(6)} ${min.toString().padStart(6)} ${max
305
+ .toString()
306
+ .padStart(6)} ${range.toString().padStart(8)}\n`;
307
+ }
308
+ liveTable += "\nMove joints through their full range...";
309
+
310
+ // Update the display in place (no new console lines!)
311
+ logUpdate(liveTable);
312
+ }
313
+
314
+ // Minimal delay for 30Hz reading rate (~33ms cycle time)
315
+ await new Promise((resolve) => setTimeout(resolve, 10));
316
+ } catch (error) {
317
+ console.warn(
318
+ `Read error: ${error instanceof Error ? error.message : error}`
319
+ );
320
+ await new Promise((resolve) => setTimeout(resolve, 100));
321
+ }
322
+ }
323
+
324
+ // Stop live updating and return to normal console
325
+ logUpdate.done();
326
+
327
+ return { rangeMins, rangeMaxes };
328
+ }
329
+
330
+ /**
331
+ * Prompt user for input (real implementation with readline)
332
+ */
333
+ async function promptUser(message: string): Promise<string> {
334
+ const rl = readline.createInterface({
335
+ input: process.stdin,
336
+ output: process.stdout,
337
+ });
338
+
339
+ return new Promise((resolve) => {
340
+ rl.question(message, (answer) => {
341
+ rl.close();
342
+ resolve(answer);
343
+ });
344
+ });
345
+ }
346
+
347
+ /**
348
+ * Read data from serial port with timeout
349
+ */
350
+ async function readData(
351
+ port: SerialPort,
352
+ timeout: number = 5000
353
+ ): Promise<Buffer> {
354
+ if (!port || !port.isOpen) {
355
+ throw new Error("Serial port not open");
356
+ }
357
+
358
+ return new Promise<Buffer>((resolve, reject) => {
359
+ const timer = setTimeout(() => {
360
+ reject(new Error("Read timeout"));
361
+ }, timeout);
362
+
363
+ port.once("data", (data: Buffer) => {
364
+ clearTimeout(timer);
365
+ resolve(data);
366
+ });
367
+ });
368
+ }
src/lerobot/node/common/so100_config.ts ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * SO-100 device configurations
3
+ * Defines the differences between leader and follower devices
4
+ * Mirrors Python lerobot device configuration approach
5
+ */
6
+
7
+ import type { SO100CalibrationConfig } from "./calibration.js";
8
+ import { SerialPort } from "serialport";
9
+
10
+ /**
11
+ * Common motor names for all SO-100 devices
12
+ */
13
+ const SO100_MOTOR_NAMES = [
14
+ "shoulder_pan",
15
+ "shoulder_lift",
16
+ "elbow_flex",
17
+ "wrist_flex",
18
+ "wrist_roll",
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
29
+ ): SO100CalibrationConfig {
30
+ return {
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
+ }
50
+
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
58
+ ): SO100CalibrationConfig {
59
+ return {
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
+ }
80
+
81
+ /**
82
+ * Get configuration for any SO-100 device type
83
+ */
84
+ export function getSO100Config(
85
+ deviceType: "so100_follower" | "so100_leader",
86
+ port: SerialPort
87
+ ): SO100CalibrationConfig {
88
+ switch (deviceType) {
89
+ case "so100_follower":
90
+ return createSO100FollowerConfig(port);
91
+ case "so100_leader":
92
+ return createSO100LeaderConfig(port);
93
+ default:
94
+ throw new Error(`Unknown SO-100 device type: ${deviceType}`);
95
+ }
96
+ }
src/lerobot/node/constants.ts ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Constants for lerobot.js
3
+ * Mirrors Python lerobot/common/constants.py
4
+ */
5
+
6
+ import { homedir } from "os";
7
+ import { join } from "path";
8
+
9
+ // Device types
10
+ export const ROBOTS = "robots";
11
+ export const TELEOPERATORS = "teleoperators";
12
+
13
+ /**
14
+ * Get HF Home directory
15
+ * Equivalent to Python's huggingface_hub.constants.HF_HOME
16
+ */
17
+ export function getHfHome(): string {
18
+ if (process.env.HF_HOME) {
19
+ return process.env.HF_HOME;
20
+ }
21
+
22
+ const homeDir = homedir();
23
+ return join(homeDir, ".cache", "huggingface");
24
+ }
25
+
26
+ /**
27
+ * Get HF lerobot home directory
28
+ * Equivalent to Python's HF_LEROBOT_HOME
29
+ */
30
+ export function getHfLerobotHome(): string {
31
+ if (process.env.HF_LEROBOT_HOME) {
32
+ return process.env.HF_LEROBOT_HOME;
33
+ }
34
+
35
+ return join(getHfHome(), "lerobot");
36
+ }
37
+
38
+ /**
39
+ * Get calibration directory
40
+ * Equivalent to Python's HF_LEROBOT_CALIBRATION
41
+ */
42
+ export function getCalibrationDir(): string {
43
+ if (process.env.HF_LEROBOT_CALIBRATION) {
44
+ return process.env.HF_LEROBOT_CALIBRATION;
45
+ }
46
+
47
+ return join(getHfLerobotHome(), "calibration");
48
+ }
src/lerobot/node/find_port.ts ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
9
+ * ```
10
+ */
11
+
12
+ import { SerialPort } from "serialport";
13
+ import { createInterface } from "readline";
14
+ import { platform } from "os";
15
+ import { readdir } from "fs/promises";
16
+ import { join } from "path";
17
+
18
+ /**
19
+ * Find all available serial ports on the system
20
+ * Mirrors Python's find_available_ports() function
21
+ */
22
+ async function findAvailablePorts(): Promise<string[]> {
23
+ if (platform() === "win32") {
24
+ // List COM ports using serialport library (equivalent to pyserial)
25
+ const ports = await SerialPort.list();
26
+ return ports.map((port) => port.path);
27
+ } else {
28
+ // List /dev/tty* ports for Unix-based systems (Linux/macOS)
29
+ try {
30
+ const devFiles = await readdir("/dev");
31
+ const ttyPorts = devFiles
32
+ .filter((file) => file.startsWith("tty"))
33
+ .map((file) => join("/dev", file));
34
+ return ttyPorts;
35
+ } catch (error) {
36
+ // Fallback to serialport library if /dev reading fails
37
+ const ports = await SerialPort.list();
38
+ return ports.map((port) => port.path);
39
+ }
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Create readline interface for user input
45
+ * Equivalent to Python's input() function
46
+ */
47
+ function createReadlineInterface() {
48
+ return createInterface({
49
+ input: process.stdin,
50
+ output: process.stdout,
51
+ });
52
+ }
53
+
54
+ /**
55
+ * Prompt user for input and wait for response
56
+ * Equivalent to Python's input() function
57
+ */
58
+ function waitForInput(prompt: string = ""): Promise<string> {
59
+ const rl = createReadlineInterface();
60
+ return new Promise((resolve) => {
61
+ if (prompt) {
62
+ process.stdout.write(prompt);
63
+ }
64
+ rl.on("line", (answer) => {
65
+ rl.close();
66
+ resolve(answer);
67
+ });
68
+ });
69
+ }
70
+
71
+ /**
72
+ * Sleep for specified milliseconds
73
+ * Equivalent to Python's time.sleep()
74
+ */
75
+ function sleep(ms: number): Promise<void> {
76
+ return new Promise((resolve) => setTimeout(resolve, ms));
77
+ }
78
+
79
+ /**
80
+ * Main find port function - direct port of Python find_port()
81
+ * Maintains identical UX and messaging
82
+ */
83
+ export async function findPort(): Promise<void> {
84
+ console.log("Finding all available ports for the MotorsBus.");
85
+
86
+ const portsBefore = await findAvailablePorts();
87
+ console.log("Ports before disconnecting:", portsBefore);
88
+
89
+ console.log(
90
+ "Remove the USB cable from your MotorsBus and press Enter when done."
91
+ );
92
+ await waitForInput();
93
+
94
+ // Allow some time for port to be released (equivalent to Python's time.sleep(0.5))
95
+ await sleep(500);
96
+
97
+ const portsAfter = await findAvailablePorts();
98
+ const portsDiff = portsBefore.filter((port) => !portsAfter.includes(port));
99
+
100
+ if (portsDiff.length === 1) {
101
+ const port = portsDiff[0];
102
+ console.log(`The port of this MotorsBus is '${port}'`);
103
+ console.log("Reconnect the USB cable.");
104
+ } else if (portsDiff.length === 0) {
105
+ throw new Error(
106
+ `Could not detect the port. No difference was found (${JSON.stringify(
107
+ portsDiff
108
+ )}).`
109
+ );
110
+ } else {
111
+ throw new Error(
112
+ `Could not detect the port. More than one port was found (${JSON.stringify(
113
+ portsDiff
114
+ )}).`
115
+ );
116
+ }
117
+ }
118
+
119
+ /**
120
+ * CLI entry point when called directly
121
+ */
122
+ if (import.meta.url === `file://${process.argv[1]}`) {
123
+ findPort().catch((error) => {
124
+ console.error(error.message);
125
+ process.exit(1);
126
+ });
127
+ }
src/lerobot/node/robots/config.ts ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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;
11
+ id?: string;
12
+ calibration_dir?: string;
13
+ // SO-100 specific options
14
+ disable_torque_on_disconnect?: boolean;
15
+ max_relative_target?: number | null;
16
+ use_degrees?: boolean;
17
+ }
18
+
19
+ export interface CalibrateConfig {
20
+ robot?: RobotConfig;
21
+ teleop?: TeleoperatorConfig;
22
+ }
src/lerobot/node/robots/robot.ts ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Base Robot class for Node.js platform
3
+ * Uses serialport package for serial communication
4
+ * Mirrors Python lerobot/common/robots/robot.py
5
+ */
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;
15
+ protected config: RobotConfig;
16
+ protected calibrationDir: string;
17
+ protected calibrationPath: string;
18
+ protected name: string;
19
+
20
+ constructor(config: RobotConfig) {
21
+ this.config = config;
22
+ this.name = config.type;
23
+
24
+ // Determine calibration directory
25
+ // Mirrors Python: config.calibration_dir if config.calibration_dir else HF_LEROBOT_CALIBRATION / ROBOTS / self.name
26
+ this.calibrationDir =
27
+ config.calibration_dir || join(getCalibrationDir(), ROBOTS, this.name);
28
+
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
+ /**
35
+ * Connect to the robot
36
+ * Mirrors Python robot.connect()
37
+ */
38
+ async connect(_calibrate: boolean = false): Promise<void> {
39
+ try {
40
+ this.port = new SerialPort({
41
+ path: this.config.port,
42
+ baudRate: 1000000, // Default baud rate for Feetech motors (SO-100) - matches Python lerobot
43
+ dataBits: 8, // 8 data bits - matches Python serial.EIGHTBITS
44
+ stopBits: 1, // 1 stop bit - matches Python default
45
+ parity: "none", // No parity - matches Python default
46
+ autoOpen: false,
47
+ });
48
+
49
+ // Open the port
50
+ await new Promise<void>((resolve, reject) => {
51
+ this.port!.open((error) => {
52
+ if (error) {
53
+ reject(
54
+ new Error(
55
+ `Failed to open port ${this.config.port}: ${error.message}`
56
+ )
57
+ );
58
+ } else {
59
+ resolve();
60
+ }
61
+ });
62
+ });
63
+ } catch (error) {
64
+ throw new Error(`Could not connect to robot on port ${this.config.port}`);
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Calibrate the robot
70
+ * Must be implemented by subclasses
71
+ */
72
+ abstract calibrate(): Promise<void>;
73
+
74
+ /**
75
+ * Disconnect from the robot
76
+ * Mirrors Python robot.disconnect()
77
+ */
78
+ async disconnect(): Promise<void> {
79
+ if (this.port && this.port.isOpen) {
80
+ // Handle torque disable if configured
81
+ if (this.config.disable_torque_on_disconnect) {
82
+ await this.disableTorque();
83
+ }
84
+
85
+ await new Promise<void>((resolve) => {
86
+ this.port!.close(() => {
87
+ resolve();
88
+ });
89
+ });
90
+
91
+ this.port = null;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Save calibration data to JSON file
97
+ * Mirrors Python's configuration saving
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(
105
+ this.calibrationPath,
106
+ JSON.stringify(calibrationData, null, 2)
107
+ );
108
+
109
+ console.log(`Configuration saved to: ${this.calibrationPath}`);
110
+ }
111
+
112
+ /**
113
+ * Send command to robot via serial port
114
+ */
115
+ protected async sendCommand(command: string): Promise<void> {
116
+ if (!this.port || !this.port.isOpen) {
117
+ throw new Error("Robot not connected");
118
+ }
119
+
120
+ return new Promise<void>((resolve, reject) => {
121
+ this.port!.write(command, (error) => {
122
+ if (error) {
123
+ reject(new Error(`Failed to send command: ${error.message}`));
124
+ } else {
125
+ resolve();
126
+ }
127
+ });
128
+ });
129
+ }
130
+
131
+ /**
132
+ * Read data from robot
133
+ */
134
+ protected async readData(timeout: number = 5000): Promise<Buffer> {
135
+ if (!this.port || !this.port.isOpen) {
136
+ throw new Error("Robot not connected");
137
+ }
138
+
139
+ return new Promise<Buffer>((resolve, reject) => {
140
+ const timer = setTimeout(() => {
141
+ reject(new Error("Read timeout"));
142
+ }, timeout);
143
+
144
+ this.port!.once("data", (data: Buffer) => {
145
+ clearTimeout(timer);
146
+ resolve(data);
147
+ });
148
+ });
149
+ }
150
+
151
+ /**
152
+ * Disable torque on disconnect (SO-100 specific)
153
+ */
154
+ protected async disableTorque(): Promise<void> {
155
+ try {
156
+ await this.sendCommand("TORQUE_DISABLE\r\n");
157
+ } catch (error) {
158
+ console.warn("Warning: Could not disable torque on disconnect");
159
+ }
160
+ }
161
+ }
src/lerobot/node/robots/so100_follower.ts ADDED
@@ -0,0 +1,465 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * SO-100 Follower Robot implementation for Node.js
3
+ * Mirrors Python lerobot/common/robots/so100_follower/so100_follower.py
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 {
11
+ constructor(config: RobotConfig) {
12
+ super(config);
13
+
14
+ // Validate that this is an SO-100 follower config
15
+ if (config.type !== "so100_follower") {
16
+ throw new Error(
17
+ `Invalid robot type: ${config.type}. Expected: so100_follower`
18
+ );
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Calibrate the SO-100 follower robot
24
+ * NOTE: Calibration logic has been moved to shared/common/calibration.ts
25
+ * This method is kept for backward compatibility but delegates to the main calibrate.ts
26
+ */
27
+ async calibrate(): Promise<void> {
28
+ throw new Error(
29
+ "Direct device calibration is deprecated. Use the main calibrate.ts orchestrator instead."
30
+ );
31
+ }
32
+
33
+ /**
34
+ * Initialize robot communication
35
+ * For now, just test basic serial connectivity
36
+ */
37
+ private async initializeRobot(): Promise<void> {
38
+ console.log("Initializing robot communication...");
39
+
40
+ try {
41
+ // For SO-100, we need to implement Feetech servo protocol
42
+ // For now, just test that we can send/receive data
43
+ console.log("Testing serial port connectivity...");
44
+
45
+ // Try to ping servo ID 1 (shoulder_pan motor)
46
+ // This is a very basic test - real implementation needs proper Feetech protocol
47
+ const pingPacket = Buffer.from([0xff, 0xff, 0x01, 0x02, 0x01, 0xfb]); // Basic ping packet
48
+
49
+ if (!this.port || !this.port.isOpen) {
50
+ throw new Error("Serial port not open");
51
+ }
52
+
53
+ // Send ping packet
54
+ await new Promise<void>((resolve, reject) => {
55
+ this.port!.write(pingPacket, (error) => {
56
+ if (error) {
57
+ reject(new Error(`Failed to send ping: ${error.message}`));
58
+ } else {
59
+ resolve();
60
+ }
61
+ });
62
+ });
63
+
64
+ console.log("Ping packet sent successfully");
65
+
66
+ // Try to read response with shorter timeout
67
+ try {
68
+ const response = await this.readData(1000); // 1 second timeout
69
+ console.log(`Response received: ${response.length} bytes`);
70
+ } catch (error) {
71
+ console.log("No response received (expected for basic test)");
72
+ }
73
+ } catch (error) {
74
+ throw new Error(
75
+ `Serial communication test failed: ${
76
+ error instanceof Error ? error.message : error
77
+ }`
78
+ );
79
+ }
80
+
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
87
+ */
88
+ private async readMotorPositions(): Promise<number[]> {
89
+ console.log("Reading motor positions...");
90
+
91
+ const motorPositions: number[] = [];
92
+ const motorIds = [1, 2, 3, 4, 5, 6]; // SO-100 has servo IDs 1-6
93
+ const motorNames = [
94
+ "shoulder_pan",
95
+ "shoulder_lift",
96
+ "elbow_flex",
97
+ "wrist_flex",
98
+ "wrist_roll",
99
+ "gripper",
100
+ ];
101
+
102
+ // Try to read position from each servo using STS3215 protocol
103
+ for (let i = 0; i < motorIds.length; i++) {
104
+ const motorId = motorIds[i];
105
+ const motorName = motorNames[i];
106
+
107
+ try {
108
+ console.log(` Reading ${motorName} (ID ${motorId})...`);
109
+
110
+ // Create STS3215 Read Position packet
111
+ // Format: [0xFF, 0xFF, ID, Length, Instruction, Address, DataLength, Checksum]
112
+ // Present_Position address for STS3215 is 56 (0x38), length 2 bytes
113
+ const packet = Buffer.from([
114
+ 0xff,
115
+ 0xff, // Header
116
+ motorId, // Servo ID
117
+ 0x04, // Length (Instruction + Address + DataLength + Checksum)
118
+ 0x02, // Instruction: READ_DATA
119
+ 0x38, // Address: Present_Position (56)
120
+ 0x02, // Data Length: 2 bytes
121
+ 0x00, // Checksum (will calculate)
122
+ ]);
123
+
124
+ // Calculate checksum: ~(ID + Length + Instruction + Address + DataLength) & 0xFF
125
+ const checksum = ~(motorId + 0x04 + 0x02 + 0x38 + 0x02) & 0xff;
126
+ packet[7] = checksum;
127
+
128
+ if (!this.port || !this.port.isOpen) {
129
+ throw new Error("Serial port not open");
130
+ }
131
+
132
+ // Send read position packet
133
+ await new Promise<void>((resolve, reject) => {
134
+ this.port!.write(packet, (error) => {
135
+ if (error) {
136
+ reject(new Error(`Failed to send read packet: ${error.message}`));
137
+ } else {
138
+ resolve();
139
+ }
140
+ });
141
+ });
142
+
143
+ // Try to read response (timeout after 500ms)
144
+ try {
145
+ const response = await this.readData(500);
146
+
147
+ if (response.length >= 7) {
148
+ // Parse response: [0xFF, 0xFF, ID, Length, Error, Data_L, Data_H, Checksum]
149
+ const id = response[2];
150
+ const error = response[4];
151
+
152
+ if (id === motorId && error === 0) {
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})`
160
+ );
161
+ motorPositions.push(2047); // Use center position as fallback
162
+ }
163
+ } else {
164
+ console.warn(` ${motorName}: Invalid response length`);
165
+ motorPositions.push(2047); // Use center position as fallback
166
+ }
167
+ } catch (readError) {
168
+ console.warn(
169
+ ` ${motorName}: Read timeout - using fallback position`
170
+ );
171
+ motorPositions.push(2047); // Use center position as fallback
172
+ }
173
+ } catch (error) {
174
+ console.warn(
175
+ ` ${motorName}: Communication error - ${
176
+ error instanceof Error ? error.message : error
177
+ }`
178
+ );
179
+ motorPositions.push(2047); // Use center position as fallback
180
+ }
181
+
182
+ // Small delay between servo reads
183
+ await new Promise((resolve) => setTimeout(resolve, 10));
184
+ }
185
+
186
+ console.log(`Motor positions: [${motorPositions.join(", ")}]`);
187
+ return motorPositions;
188
+ }
189
+
190
+ /**
191
+ * Set motor limits and safety parameters
192
+ * TODO: Implement proper Feetech servo protocol
193
+ */
194
+ private async setMotorLimits(): Promise<any> {
195
+ console.log("Setting motor limits...");
196
+
197
+ // Set default limits for SO-100 (based on Python implementation)
198
+ const limits = {
199
+ position_min: [-180, -90, -90, -90, -90, -90],
200
+ position_max: [180, 90, 90, 90, 90, 90],
201
+ velocity_max: [100, 100, 100, 100, 100, 100],
202
+ torque_max: [50, 50, 50, 50, 25, 25],
203
+ };
204
+
205
+ // For now, just return the limits without sending to robot
206
+ // Real implementation needs Feetech servo protocol to set limits
207
+ console.log("Motor limits configured (mock).");
208
+ return limits;
209
+ }
210
+
211
+ /**
212
+ * Interactive calibration process - matches Python lerobot calibration flow
213
+ * Implements real calibration with user interaction
214
+ */
215
+ private async calibrateMotors(): Promise<any> {
216
+ console.log("\n=== INTERACTIVE CALIBRATION ===");
217
+ console.log("Starting SO-100 follower arm calibration...");
218
+
219
+ // Step 1: Move to middle position and record homing offsets
220
+ console.log("\n📍 STEP 1: Set Homing Position");
221
+ await this.promptUser(
222
+ "Move the SO-100 to the MIDDLE of its range of motion and press ENTER..."
223
+ );
224
+
225
+ const homingOffsets = await this.setHomingOffsets();
226
+
227
+ // Step 2: Record ranges of motion
228
+ console.log("\n📏 STEP 2: Record Joint Ranges");
229
+ const { rangeMins, rangeMaxes } = await this.recordRangesOfMotion();
230
+
231
+ // Step 3: Set special range for wrist_roll (full turn motor)
232
+ console.log("\n🔄 STEP 3: Configure Full-Turn Motor");
233
+ console.log("Setting wrist_roll as full-turn motor (0-4095 range)");
234
+ rangeMins["wrist_roll"] = 0;
235
+ rangeMaxes["wrist_roll"] = 4095;
236
+
237
+ // Step 4: Compile calibration results
238
+ const motorNames = [
239
+ "shoulder_pan",
240
+ "shoulder_lift",
241
+ "elbow_flex",
242
+ "wrist_flex",
243
+ "wrist_roll",
244
+ "gripper",
245
+ ];
246
+ const results = [];
247
+
248
+ for (let i = 0; i < motorNames.length; i++) {
249
+ const motorId = i + 1; // Servo IDs are 1-6
250
+ const motorName = motorNames[i];
251
+
252
+ results.push({
253
+ motor: motorId,
254
+ name: motorName,
255
+ status: "success",
256
+ homing_offset: homingOffsets[motorName],
257
+ range_min: rangeMins[motorName],
258
+ range_max: rangeMaxes[motorName],
259
+ range_size: rangeMaxes[motorName] - rangeMins[motorName],
260
+ });
261
+
262
+ console.log(
263
+ `✅ ${motorName} calibrated: range ${rangeMins[motorName]} to ${rangeMaxes[motorName]} (offset: ${homingOffsets[motorName]})`
264
+ );
265
+ }
266
+
267
+ console.log("\n🎉 Interactive calibration completed!");
268
+ return results;
269
+ }
270
+
271
+ /**
272
+ * Verify calibration was successful
273
+ * TODO: Implement proper verification with Feetech servo protocol
274
+ */
275
+ private async verifyCalibration(): Promise<void> {
276
+ console.log("Verifying calibration...");
277
+
278
+ // For now, just mock successful verification
279
+ // Real implementation should check:
280
+ // 1. All motors respond to ping
281
+ // 2. Position limits are set correctly
282
+ // 3. Homing offsets are applied
283
+ // 4. Motors can move to test positions
284
+
285
+ console.log("Calibration verification passed (mock).");
286
+ }
287
+
288
+ /**
289
+ * Prompt user for input (like Python's input() function)
290
+ */
291
+ private async promptUser(message: string): Promise<string> {
292
+ const rl = readline.createInterface({
293
+ input: process.stdin,
294
+ output: process.stdout,
295
+ });
296
+
297
+ return new Promise((resolve) => {
298
+ rl.question(message, (answer) => {
299
+ rl.close();
300
+ resolve(answer);
301
+ });
302
+ });
303
+ }
304
+
305
+ /**
306
+ * Record homing offsets (current positions as center)
307
+ * Mirrors Python bus.set_half_turn_homings()
308
+ */
309
+ private async setHomingOffsets(): Promise<{ [motor: string]: number }> {
310
+ console.log("Recording current positions as homing offsets...");
311
+
312
+ const currentPositions = await this.readMotorPositions();
313
+ const motorNames = [
314
+ "shoulder_pan",
315
+ "shoulder_lift",
316
+ "elbow_flex",
317
+ "wrist_flex",
318
+ "wrist_roll",
319
+ "gripper",
320
+ ];
321
+ const homingOffsets: { [motor: string]: number } = {};
322
+
323
+ for (let i = 0; i < motorNames.length; i++) {
324
+ const motorName = motorNames[i];
325
+ const position = currentPositions[i];
326
+ // Calculate homing offset (half turn offset from current position)
327
+ const maxRes = 4095; // STS3215 resolution
328
+ homingOffsets[motorName] = position - Math.floor(maxRes / 2);
329
+ console.log(
330
+ ` ${motorName}: offset ${homingOffsets[motorName]} (current pos: ${position})`
331
+ );
332
+ }
333
+
334
+ return homingOffsets;
335
+ }
336
+
337
+ /**
338
+ * Record ranges of motion by continuously reading positions
339
+ * Mirrors Python bus.record_ranges_of_motion()
340
+ */
341
+ private async recordRangesOfMotion(): Promise<{
342
+ rangeMins: { [motor: string]: number };
343
+ rangeMaxes: { [motor: string]: number };
344
+ }> {
345
+ console.log("\n=== RECORDING RANGES OF MOTION ===");
346
+ console.log(
347
+ "Move all joints sequentially through their entire ranges of motion."
348
+ );
349
+ console.log(
350
+ "Positions will be recorded continuously. Press ENTER to stop...\n"
351
+ );
352
+
353
+ const motorNames = [
354
+ "shoulder_pan",
355
+ "shoulder_lift",
356
+ "elbow_flex",
357
+ "wrist_flex",
358
+ "wrist_roll",
359
+ "gripper",
360
+ ];
361
+ const rangeMins: { [motor: string]: number } = {};
362
+ const rangeMaxes: { [motor: string]: number } = {};
363
+
364
+ // Initialize with current positions
365
+ const initialPositions = await this.readMotorPositions();
366
+ for (let i = 0; i < motorNames.length; i++) {
367
+ const motorName = motorNames[i];
368
+ const position = initialPositions[i];
369
+ rangeMins[motorName] = position;
370
+ rangeMaxes[motorName] = position;
371
+ }
372
+
373
+ let recording = true;
374
+ let readCount = 0;
375
+
376
+ // Set up readline to detect Enter key
377
+ const rl = readline.createInterface({
378
+ input: process.stdin,
379
+ output: process.stdout,
380
+ });
381
+
382
+ rl.on("line", () => {
383
+ recording = false;
384
+ rl.close();
385
+ });
386
+
387
+ console.log("Recording started... (move the robot joints now)");
388
+
389
+ // Continuous recording loop
390
+ while (recording) {
391
+ try {
392
+ const positions = await this.readMotorPositions();
393
+ readCount++;
394
+
395
+ // Update min/max ranges
396
+ for (let i = 0; i < motorNames.length; i++) {
397
+ const motorName = motorNames[i];
398
+ const position = positions[i];
399
+
400
+ if (position < rangeMins[motorName]) {
401
+ rangeMins[motorName] = position;
402
+ }
403
+ if (position > rangeMaxes[motorName]) {
404
+ rangeMaxes[motorName] = position;
405
+ }
406
+ }
407
+
408
+ // Show real-time feedback every 10 reads
409
+ if (readCount % 10 === 0) {
410
+ console.clear(); // Clear screen for live update
411
+ console.log("=== LIVE POSITION RECORDING ===");
412
+ console.log(`Readings: ${readCount} | Press ENTER to stop\n`);
413
+
414
+ console.log("Motor Name Current Min Max Range");
415
+ console.log("─".repeat(55));
416
+
417
+ for (let i = 0; i < motorNames.length; i++) {
418
+ const motorName = motorNames[i];
419
+ const current = positions[i];
420
+ const min = rangeMins[motorName];
421
+ const max = rangeMaxes[motorName];
422
+ const range = max - min;
423
+
424
+ console.log(
425
+ `${motorName.padEnd(15)} ${current.toString().padStart(6)} ${min
426
+ .toString()
427
+ .padStart(6)} ${max.toString().padStart(6)} ${range
428
+ .toString()
429
+ .padStart(8)}`
430
+ );
431
+ }
432
+ console.log("\nMove joints through their full range...");
433
+ }
434
+
435
+ // Small delay to avoid overwhelming the serial port
436
+ await new Promise((resolve) => setTimeout(resolve, 50));
437
+ } catch (error) {
438
+ console.warn(
439
+ `Read error: ${error instanceof Error ? error.message : error}`
440
+ );
441
+ await new Promise((resolve) => setTimeout(resolve, 100));
442
+ }
443
+ }
444
+
445
+ console.log(`\nRecording stopped after ${readCount} readings.`);
446
+ console.log("\nFinal ranges recorded:");
447
+
448
+ for (const motorName of motorNames) {
449
+ const min = rangeMins[motorName];
450
+ const max = rangeMaxes[motorName];
451
+ const range = max - min;
452
+ console.log(` ${motorName}: ${min} to ${max} (range: ${range})`);
453
+ }
454
+
455
+ return { rangeMins, rangeMaxes };
456
+ }
457
+ }
458
+
459
+ /**
460
+ * Factory function to create SO-100 follower robot
461
+ * Mirrors Python's make_robot_from_config pattern
462
+ */
463
+ export function createSO100Follower(config: RobotConfig): SO100Follower {
464
+ return new SO100Follower(config);
465
+ }
src/lerobot/node/teleoperators/config.ts ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Teleoperator configuration types
3
+ * Shared between Node.js and Web implementations
4
+ */
5
+
6
+ export interface TeleoperatorConfig {
7
+ type: "so100_leader";
8
+ port: string;
9
+ id?: string;
10
+ calibration_dir?: string;
11
+ // SO-100 leader specific options
12
+ }
src/lerobot/node/teleoperators/so100_leader.ts ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * SO-100 Leader Teleoperator implementation for Node.js
3
+ *
4
+ * Minimal implementation - calibration logic moved to shared/common/calibration.ts
5
+ * This class only handles connection management and basic device operations
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) {
13
+ super(config);
14
+
15
+ // Validate that this is an SO-100 leader config
16
+ if (config.type !== "so100_leader") {
17
+ throw new Error(
18
+ `Invalid teleoperator type: ${config.type}. Expected: so100_leader`
19
+ );
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Calibrate the SO-100 leader teleoperator
25
+ * NOTE: Calibration logic has been moved to shared/common/calibration.ts
26
+ * This method is kept for backward compatibility but delegates to the main calibrate.ts
27
+ */
28
+ async calibrate(): Promise<void> {
29
+ throw new Error(
30
+ "Direct device calibration is deprecated. Use the main calibrate.ts orchestrator instead."
31
+ );
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Factory function to create SO-100 leader teleoperator
37
+ * Mirrors Python's make_teleoperator_from_config pattern
38
+ */
39
+ export function createSO100Leader(config: TeleoperatorConfig): SO100Leader {
40
+ return new SO100Leader(config);
41
+ }
src/lerobot/node/teleoperators/teleoperator.ts ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Base Teleoperator class for Node.js platform
3
+ * Uses serialport package for serial communication
4
+ * Mirrors Python lerobot/common/teleoperators/teleoperator.py
5
+ */
6
+
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;
15
+ protected config: TeleoperatorConfig;
16
+ protected calibrationDir: string;
17
+ protected calibrationPath: string;
18
+ protected name: string;
19
+
20
+ constructor(config: TeleoperatorConfig) {
21
+ this.config = config;
22
+ this.name = config.type;
23
+
24
+ // Determine calibration directory
25
+ // Mirrors Python: config.calibration_dir if config.calibration_dir else HF_LEROBOT_CALIBRATION / TELEOPERATORS / self.name
26
+ this.calibrationDir =
27
+ config.calibration_dir ||
28
+ join(getCalibrationDir(), TELEOPERATORS, this.name);
29
+
30
+ // Use teleoperator ID or type as filename
31
+ const teleopId = config.id || this.name;
32
+ this.calibrationPath = join(this.calibrationDir, `${teleopId}.json`);
33
+ }
34
+
35
+ /**
36
+ * Connect to the teleoperator
37
+ * Mirrors Python teleoperator.connect()
38
+ */
39
+ async connect(_calibrate: boolean = false): Promise<void> {
40
+ try {
41
+ this.port = new SerialPort({
42
+ path: this.config.port,
43
+ baudRate: 1000000, // Correct baud rate for Feetech motors (SO-100) - matches Python lerobot
44
+ dataBits: 8, // 8 data bits - matches Python serial.EIGHTBITS
45
+ stopBits: 1, // 1 stop bit - matches Python default
46
+ parity: "none", // No parity - matches Python default
47
+ autoOpen: false,
48
+ });
49
+
50
+ // Open the port
51
+ await new Promise<void>((resolve, reject) => {
52
+ this.port!.open((error) => {
53
+ if (error) {
54
+ reject(
55
+ new Error(
56
+ `Failed to open port ${this.config.port}: ${error.message}`
57
+ )
58
+ );
59
+ } else {
60
+ resolve();
61
+ }
62
+ });
63
+ });
64
+ } catch (error) {
65
+ throw new Error(
66
+ `Could not connect to teleoperator on port ${this.config.port}`
67
+ );
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Calibrate the teleoperator
73
+ * Must be implemented by subclasses
74
+ */
75
+ abstract calibrate(): Promise<void>;
76
+
77
+ /**
78
+ * Disconnect from the teleoperator
79
+ * Mirrors Python teleoperator.disconnect()
80
+ */
81
+ async disconnect(): Promise<void> {
82
+ if (this.port && this.port.isOpen) {
83
+ await new Promise<void>((resolve) => {
84
+ this.port!.close(() => {
85
+ resolve();
86
+ });
87
+ });
88
+
89
+ this.port = null;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Save calibration data to JSON file
95
+ * Mirrors Python's configuration saving
96
+ */
97
+ protected async saveCalibration(calibrationData: any): Promise<void> {
98
+ // Ensure calibration directory exists
99
+ await mkdir(this.calibrationDir, { recursive: true });
100
+
101
+ // Save calibration data as JSON
102
+ await writeFile(
103
+ this.calibrationPath,
104
+ JSON.stringify(calibrationData, null, 2)
105
+ );
106
+
107
+ console.log(`Configuration saved to: ${this.calibrationPath}`);
108
+ }
109
+
110
+ /**
111
+ * Send command to teleoperator via serial port
112
+ */
113
+ protected async sendCommand(command: string): Promise<void> {
114
+ if (!this.port || !this.port.isOpen) {
115
+ throw new Error("Teleoperator not connected");
116
+ }
117
+
118
+ return new Promise<void>((resolve, reject) => {
119
+ this.port!.write(command, (error) => {
120
+ if (error) {
121
+ reject(new Error(`Failed to send command: ${error.message}`));
122
+ } else {
123
+ resolve();
124
+ }
125
+ });
126
+ });
127
+ }
128
+
129
+ /**
130
+ * Read data from teleoperator
131
+ */
132
+ protected async readData(timeout: number = 5000): Promise<Buffer> {
133
+ if (!this.port || !this.port.isOpen) {
134
+ throw new Error("Teleoperator not connected");
135
+ }
136
+
137
+ return new Promise<Buffer>((resolve, reject) => {
138
+ const timer = setTimeout(() => {
139
+ reject(new Error("Read timeout"));
140
+ }, timeout);
141
+
142
+ this.port!.once("data", (data: Buffer) => {
143
+ clearTimeout(timer);
144
+ resolve(data);
145
+ });
146
+ });
147
+ }
148
+ }