Spaces:
Running
Running
recover: restore deleted src/lerobot/node/ folder from ec936d5
Browse files- examples/demo/App.tsx +19 -0
- examples/demo/components/CalibrationModal.tsx +49 -0
- examples/demo/components/CalibrationPanel.tsx +419 -0
- examples/demo/components/ErrorBoundary.tsx +65 -0
- examples/demo/components/PortManager.tsx +1251 -0
- examples/demo/components/TeleoperationPanel.tsx +530 -0
- examples/demo/components/ui/alert.tsx +58 -0
- examples/demo/components/ui/badge.tsx +35 -0
- examples/demo/components/ui/button.tsx +53 -0
- examples/demo/components/ui/card.tsx +85 -0
- examples/demo/components/ui/dialog.tsx +120 -0
- examples/demo/components/ui/progress.tsx +26 -0
- examples/demo/index.css +12 -0
- examples/demo/lib/unified-storage.ts +325 -0
- examples/demo/lib/utils.ts +6 -0
- examples/demo/main.tsx +5 -0
- examples/demo/pages/Home.tsx +99 -0
- src/lerobot/node/calibrate.ts +248 -0
- src/lerobot/node/common/calibration.ts +368 -0
- src/lerobot/node/common/so100_config.ts +96 -0
- src/lerobot/node/constants.ts +48 -0
- src/lerobot/node/find_port.ts +127 -0
- src/lerobot/node/robots/config.ts +22 -0
- src/lerobot/node/robots/robot.ts +161 -0
- src/lerobot/node/robots/so100_follower.ts +465 -0
- src/lerobot/node/teleoperators/config.ts +12 -0
- src/lerobot/node/teleoperators/so100_leader.ts +41 -0
- src/lerobot/node/teleoperators/teleoperator.ts +148 -0
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 |
+
}
|