Spaces:
Running
Running
feat: make the cyberpunk example work with @lerobot/web
Browse files- examples/cyberpunk-standalone/package.json +1 -0
- examples/cyberpunk-standalone/src/App.tsx +200 -45
- examples/cyberpunk-standalone/src/components/VirtualKey.tsx +53 -10
- examples/cyberpunk-standalone/src/components/calibration-view.tsx +235 -53
- examples/cyberpunk-standalone/src/components/device-dashboard.tsx +77 -33
- examples/cyberpunk-standalone/src/components/edit-robot-dialog.tsx +49 -24
- examples/cyberpunk-standalone/src/components/teleoperation-view.tsx +565 -82
- examples/cyberpunk-standalone/src/global.css +1 -25
- examples/cyberpunk-standalone/src/lib/mock-api.ts +0 -137
- examples/cyberpunk-standalone/src/lib/unified-storage.ts +137 -0
- examples/cyberpunk-standalone/src/types/robot.ts +6 -35
- examples/cyberpunk-standalone/tsconfig.tsbuildinfo +0 -0
examples/cyberpunk-standalone/package.json
CHANGED
|
@@ -10,6 +10,7 @@
|
|
| 10 |
"preview": "vite preview"
|
| 11 |
},
|
| 12 |
"dependencies": {
|
|
|
|
| 13 |
"@hookform/resolvers": "^3.9.1",
|
| 14 |
"@radix-ui/react-accordion": "1.2.2",
|
| 15 |
"@radix-ui/react-alert-dialog": "1.1.4",
|
|
|
|
| 10 |
"preview": "vite preview"
|
| 11 |
},
|
| 12 |
"dependencies": {
|
| 13 |
+
"@lerobot/web": "workspace:*",
|
| 14 |
"@hookform/resolvers": "^3.9.1",
|
| 15 |
"@radix-ui/react-accordion": "1.2.2",
|
| 16 |
"@radix-ui/react-alert-dialog": "1.1.4",
|
examples/cyberpunk-standalone/src/App.tsx
CHANGED
|
@@ -10,80 +10,241 @@ import { SetupCards } from "@/components/setup-cards";
|
|
| 10 |
import { DocsSection } from "@/components/docs-section";
|
| 11 |
import { RoadmapSection } from "@/components/roadmap-section";
|
| 12 |
import { HardwareSupportSection } from "@/components/hardware-support-section";
|
| 13 |
-
import {
|
| 14 |
-
import {
|
| 15 |
-
import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
function App() {
|
| 18 |
const [view, setView] = useState<
|
| 19 |
"dashboard" | "calibrating" | "teleoperating"
|
| 20 |
>("dashboard");
|
| 21 |
-
const [robots, setRobots] =
|
| 22 |
-
"connected-robots",
|
| 23 |
-
[]
|
| 24 |
-
);
|
| 25 |
const [selectedRobot, setSelectedRobot] = useState<RobotConnection | null>(
|
| 26 |
null
|
| 27 |
);
|
| 28 |
const [editingRobot, setEditingRobot] = useState<RobotConnection | null>(
|
| 29 |
null
|
| 30 |
);
|
| 31 |
-
const [logs, setLogs] = useState<string[]>([]);
|
| 32 |
const [isConnecting, setIsConnecting] = useState(false);
|
| 33 |
const hardwareSectionRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
useEffect(() => {
|
| 36 |
const loadSavedRobots = async () => {
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
});
|
| 43 |
-
|
| 44 |
-
|
| 45 |
}
|
| 46 |
-
setIsConnecting(false);
|
| 47 |
};
|
|
|
|
| 48 |
loadSavedRobots();
|
| 49 |
-
}, []);
|
| 50 |
|
| 51 |
const handleFindNewRobots = async () => {
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
}
|
| 67 |
-
setIsConnecting(false);
|
| 68 |
};
|
| 69 |
|
| 70 |
const handleUpdateRobot = (updatedRobot: RobotConnection) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
setRobots((prev) =>
|
| 72 |
-
prev.map((r) =>
|
|
|
|
|
|
|
| 73 |
);
|
| 74 |
setEditingRobot(null);
|
| 75 |
};
|
| 76 |
|
| 77 |
const handleRemoveRobot = (robotId: string) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
setRobots((prev) => prev.filter((r) => r.robotId !== robotId));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
};
|
| 80 |
|
| 81 |
const handleCalibrate = (robot: RobotConnection) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
setSelectedRobot(robot);
|
| 83 |
setView("calibrating");
|
| 84 |
};
|
| 85 |
|
| 86 |
const handleTeleoperate = (robot: RobotConnection) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
setSelectedRobot(robot);
|
| 88 |
setView("teleoperating");
|
| 89 |
};
|
|
@@ -139,13 +300,6 @@ function App() {
|
|
| 139 |
};
|
| 140 |
|
| 141 |
const PageHeader = () => {
|
| 142 |
-
let title = "DASHBOARD";
|
| 143 |
-
if (view === "calibrating" && selectedRobot) {
|
| 144 |
-
title = `CALIBRATE: ${selectedRobot.name.toUpperCase()}`;
|
| 145 |
-
} else if (view === "teleoperating" && selectedRobot) {
|
| 146 |
-
title = `TELEOPERATE: ${selectedRobot.name.toUpperCase()}`;
|
| 147 |
-
}
|
| 148 |
-
|
| 149 |
return (
|
| 150 |
<div className="flex items-center justify-between mb-12">
|
| 151 |
<div className="flex items-center gap-4">
|
|
@@ -157,9 +311,9 @@ function App() {
|
|
| 157 |
</span>{" "}
|
| 158 |
<span
|
| 159 |
className="text-primary text-glitch uppercase"
|
| 160 |
-
data-text={selectedRobot.
|
| 161 |
>
|
| 162 |
-
{selectedRobot.
|
| 163 |
</span>
|
| 164 |
</h1>
|
| 165 |
) : view === "teleoperating" && selectedRobot ? (
|
|
@@ -169,9 +323,9 @@ function App() {
|
|
| 169 |
</span>{" "}
|
| 170 |
<span
|
| 171 |
className="text-primary text-glitch uppercase"
|
| 172 |
-
data-text={selectedRobot.
|
| 173 |
>
|
| 174 |
-
{selectedRobot.
|
| 175 |
</span>
|
| 176 |
</h1>
|
| 177 |
) : (
|
|
@@ -202,7 +356,7 @@ function App() {
|
|
| 202 |
};
|
| 203 |
|
| 204 |
return (
|
| 205 |
-
<div className="flex flex-col min-h-screen font-sans
|
| 206 |
<Header />
|
| 207 |
<main className="flex-grow container mx-auto py-12 px-4 md:px-6">
|
| 208 |
<PageHeader />
|
|
@@ -215,6 +369,7 @@ function App() {
|
|
| 215 |
/>
|
| 216 |
</main>
|
| 217 |
<Footer />
|
|
|
|
| 218 |
</div>
|
| 219 |
);
|
| 220 |
}
|
|
|
|
| 10 |
import { DocsSection } from "@/components/docs-section";
|
| 11 |
import { RoadmapSection } from "@/components/roadmap-section";
|
| 12 |
import { HardwareSupportSection } from "@/components/hardware-support-section";
|
| 13 |
+
import { useToast } from "@/hooks/use-toast";
|
| 14 |
+
import { Toaster } from "@/components/ui/toaster";
|
| 15 |
+
import {
|
| 16 |
+
findPort,
|
| 17 |
+
isWebSerialSupported,
|
| 18 |
+
type RobotConnection,
|
| 19 |
+
type RobotConfig,
|
| 20 |
+
} from "@lerobot/web";
|
| 21 |
+
import {
|
| 22 |
+
getAllSavedRobots,
|
| 23 |
+
getUnifiedRobotData,
|
| 24 |
+
saveDeviceInfo,
|
| 25 |
+
removeRobotData,
|
| 26 |
+
type DeviceInfo,
|
| 27 |
+
} from "@/lib/unified-storage";
|
| 28 |
|
| 29 |
function App() {
|
| 30 |
const [view, setView] = useState<
|
| 31 |
"dashboard" | "calibrating" | "teleoperating"
|
| 32 |
>("dashboard");
|
| 33 |
+
const [robots, setRobots] = useState<RobotConnection[]>([]);
|
|
|
|
|
|
|
|
|
|
| 34 |
const [selectedRobot, setSelectedRobot] = useState<RobotConnection | null>(
|
| 35 |
null
|
| 36 |
);
|
| 37 |
const [editingRobot, setEditingRobot] = useState<RobotConnection | null>(
|
| 38 |
null
|
| 39 |
);
|
|
|
|
| 40 |
const [isConnecting, setIsConnecting] = useState(false);
|
| 41 |
const hardwareSectionRef = useRef<HTMLDivElement>(null);
|
| 42 |
+
const { toast } = useToast();
|
| 43 |
+
|
| 44 |
+
// Check browser support
|
| 45 |
+
const isSupported = isWebSerialSupported();
|
| 46 |
+
|
| 47 |
+
useEffect(() => {
|
| 48 |
+
if (!isSupported) {
|
| 49 |
+
toast({
|
| 50 |
+
title: "Browser Not Supported",
|
| 51 |
+
description:
|
| 52 |
+
"WebSerial API is not supported. Please use Chrome, Edge, or another Chromium-based browser.",
|
| 53 |
+
variant: "destructive",
|
| 54 |
+
});
|
| 55 |
+
}
|
| 56 |
+
}, [isSupported, toast]);
|
| 57 |
|
| 58 |
useEffect(() => {
|
| 59 |
const loadSavedRobots = async () => {
|
| 60 |
+
if (!isSupported) return;
|
| 61 |
+
|
| 62 |
+
try {
|
| 63 |
+
setIsConnecting(true);
|
| 64 |
+
|
| 65 |
+
// Get saved robot configurations
|
| 66 |
+
const savedRobots = getAllSavedRobots();
|
| 67 |
+
|
| 68 |
+
if (savedRobots.length > 0) {
|
| 69 |
+
const robotConfigs: RobotConfig[] = savedRobots.map((device) => ({
|
| 70 |
+
robotType: device.robotType as "so100_follower" | "so100_leader",
|
| 71 |
+
robotId: device.robotId,
|
| 72 |
+
serialNumber: device.serialNumber,
|
| 73 |
+
}));
|
| 74 |
+
|
| 75 |
+
// Auto-connect to saved robots
|
| 76 |
+
const findPortProcess = await findPort({
|
| 77 |
+
robotConfigs,
|
| 78 |
+
onMessage: (msg: string) => {
|
| 79 |
+
console.log("Connection message:", msg);
|
| 80 |
+
},
|
| 81 |
+
});
|
| 82 |
+
|
| 83 |
+
const reconnectedRobots = await findPortProcess.result;
|
| 84 |
+
|
| 85 |
+
// Merge saved device info (names, etc.) with fresh connection data
|
| 86 |
+
const robotsWithSavedInfo = reconnectedRobots.map((robot) => {
|
| 87 |
+
const savedData = getUnifiedRobotData(robot.serialNumber || "");
|
| 88 |
+
if (savedData?.device_info) {
|
| 89 |
+
return {
|
| 90 |
+
...robot,
|
| 91 |
+
robotId: savedData.device_info.robotId,
|
| 92 |
+
name: savedData.device_info.robotId, // Use the saved custom name
|
| 93 |
+
robotType: savedData.device_info.robotType as
|
| 94 |
+
| "so100_follower"
|
| 95 |
+
| "so100_leader",
|
| 96 |
+
};
|
| 97 |
+
}
|
| 98 |
+
return robot;
|
| 99 |
+
});
|
| 100 |
+
|
| 101 |
+
setRobots(robotsWithSavedInfo);
|
| 102 |
+
}
|
| 103 |
+
} catch (error) {
|
| 104 |
+
console.error("Failed to load saved robots:", error);
|
| 105 |
+
toast({
|
| 106 |
+
title: "Connection Error",
|
| 107 |
+
description: "Failed to reconnect to saved robots",
|
| 108 |
+
variant: "destructive",
|
| 109 |
});
|
| 110 |
+
} finally {
|
| 111 |
+
setIsConnecting(false);
|
| 112 |
}
|
|
|
|
| 113 |
};
|
| 114 |
+
|
| 115 |
loadSavedRobots();
|
| 116 |
+
}, [isSupported, toast]);
|
| 117 |
|
| 118 |
const handleFindNewRobots = async () => {
|
| 119 |
+
if (!isSupported) {
|
| 120 |
+
toast({
|
| 121 |
+
title: "Browser Not Supported",
|
| 122 |
+
description: "WebSerial API is required for robot connection",
|
| 123 |
+
variant: "destructive",
|
| 124 |
+
});
|
| 125 |
+
return;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
try {
|
| 129 |
+
setIsConnecting(true);
|
| 130 |
+
|
| 131 |
+
// Interactive mode - show browser dialog
|
| 132 |
+
const findPortProcess = await findPort({
|
| 133 |
+
onMessage: (msg: string) => {
|
| 134 |
+
console.log("Find port message:", msg);
|
| 135 |
+
},
|
| 136 |
+
});
|
| 137 |
+
|
| 138 |
+
const newRobots = await findPortProcess.result;
|
| 139 |
+
|
| 140 |
+
if (newRobots.length > 0) {
|
| 141 |
+
setRobots((prev: RobotConnection[]) => {
|
| 142 |
+
const existingSerialNumbers = new Set(
|
| 143 |
+
prev.map((r: RobotConnection) => r.serialNumber)
|
| 144 |
+
);
|
| 145 |
+
const uniqueNewRobots = newRobots.filter(
|
| 146 |
+
(r: RobotConnection) => !existingSerialNumbers.has(r.serialNumber)
|
| 147 |
+
);
|
| 148 |
+
|
| 149 |
+
// Auto-edit first new robot for configuration
|
| 150 |
+
if (uniqueNewRobots.length > 0) {
|
| 151 |
+
setEditingRobot(uniqueNewRobots[0]);
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
return [...prev, ...uniqueNewRobots];
|
| 155 |
+
});
|
| 156 |
+
|
| 157 |
+
toast({
|
| 158 |
+
title: "Robots Found",
|
| 159 |
+
description: `Found ${newRobots.length} robot(s)`,
|
| 160 |
+
});
|
| 161 |
+
} else {
|
| 162 |
+
toast({
|
| 163 |
+
title: "No Robots Found",
|
| 164 |
+
description: "No compatible devices detected",
|
| 165 |
+
});
|
| 166 |
+
}
|
| 167 |
+
} catch (error) {
|
| 168 |
+
console.error("Failed to find robots:", error);
|
| 169 |
+
toast({
|
| 170 |
+
title: "Connection Error",
|
| 171 |
+
description: "Failed to find robots. Please try again.",
|
| 172 |
+
variant: "destructive",
|
| 173 |
+
});
|
| 174 |
+
} finally {
|
| 175 |
+
setIsConnecting(false);
|
| 176 |
}
|
|
|
|
| 177 |
};
|
| 178 |
|
| 179 |
const handleUpdateRobot = (updatedRobot: RobotConnection) => {
|
| 180 |
+
// Save device info to unified storage
|
| 181 |
+
if (updatedRobot.serialNumber && updatedRobot.robotId) {
|
| 182 |
+
const deviceInfo: DeviceInfo = {
|
| 183 |
+
serialNumber: updatedRobot.serialNumber,
|
| 184 |
+
robotType: updatedRobot.robotType || "so100_follower",
|
| 185 |
+
robotId: updatedRobot.robotId,
|
| 186 |
+
usbMetadata: updatedRobot.usbMetadata
|
| 187 |
+
? {
|
| 188 |
+
vendorId: parseInt(updatedRobot.usbMetadata.vendorId || "0", 16),
|
| 189 |
+
productId: parseInt(
|
| 190 |
+
updatedRobot.usbMetadata.productId || "0",
|
| 191 |
+
16
|
| 192 |
+
),
|
| 193 |
+
serialNumber: updatedRobot.usbMetadata.serialNumber,
|
| 194 |
+
manufacturer: updatedRobot.usbMetadata.manufacturerName,
|
| 195 |
+
product: updatedRobot.usbMetadata.productName,
|
| 196 |
+
}
|
| 197 |
+
: undefined,
|
| 198 |
+
};
|
| 199 |
+
saveDeviceInfo(updatedRobot.serialNumber, deviceInfo);
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
setRobots((prev) =>
|
| 203 |
+
prev.map((r) =>
|
| 204 |
+
r.serialNumber === updatedRobot.serialNumber ? updatedRobot : r
|
| 205 |
+
)
|
| 206 |
);
|
| 207 |
setEditingRobot(null);
|
| 208 |
};
|
| 209 |
|
| 210 |
const handleRemoveRobot = (robotId: string) => {
|
| 211 |
+
const robot = robots.find((r) => r.robotId === robotId);
|
| 212 |
+
if (robot?.serialNumber) {
|
| 213 |
+
removeRobotData(robot.serialNumber);
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
setRobots((prev) => prev.filter((r) => r.robotId !== robotId));
|
| 217 |
+
|
| 218 |
+
toast({
|
| 219 |
+
title: "Robot Removed",
|
| 220 |
+
description: `${robotId} has been removed from the registry`,
|
| 221 |
+
});
|
| 222 |
};
|
| 223 |
|
| 224 |
const handleCalibrate = (robot: RobotConnection) => {
|
| 225 |
+
if (!robot.isConnected) {
|
| 226 |
+
toast({
|
| 227 |
+
title: "Robot Not Connected",
|
| 228 |
+
description: "Please connect the robot before calibrating",
|
| 229 |
+
variant: "destructive",
|
| 230 |
+
});
|
| 231 |
+
return;
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
setSelectedRobot(robot);
|
| 235 |
setView("calibrating");
|
| 236 |
};
|
| 237 |
|
| 238 |
const handleTeleoperate = (robot: RobotConnection) => {
|
| 239 |
+
if (!robot.isConnected) {
|
| 240 |
+
toast({
|
| 241 |
+
title: "Robot Not Connected",
|
| 242 |
+
description: "Please connect the robot before teleoperating",
|
| 243 |
+
variant: "destructive",
|
| 244 |
+
});
|
| 245 |
+
return;
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
setSelectedRobot(robot);
|
| 249 |
setView("teleoperating");
|
| 250 |
};
|
|
|
|
| 300 |
};
|
| 301 |
|
| 302 |
const PageHeader = () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
return (
|
| 304 |
<div className="flex items-center justify-between mb-12">
|
| 305 |
<div className="flex items-center gap-4">
|
|
|
|
| 311 |
</span>{" "}
|
| 312 |
<span
|
| 313 |
className="text-primary text-glitch uppercase"
|
| 314 |
+
data-text={selectedRobot.robotId}
|
| 315 |
>
|
| 316 |
+
{selectedRobot.robotId?.toUpperCase()}
|
| 317 |
</span>
|
| 318 |
</h1>
|
| 319 |
) : view === "teleoperating" && selectedRobot ? (
|
|
|
|
| 323 |
</span>{" "}
|
| 324 |
<span
|
| 325 |
className="text-primary text-glitch uppercase"
|
| 326 |
+
data-text={selectedRobot.robotId}
|
| 327 |
>
|
| 328 |
+
{selectedRobot.robotId?.toUpperCase()}
|
| 329 |
</span>
|
| 330 |
</h1>
|
| 331 |
) : (
|
|
|
|
| 356 |
};
|
| 357 |
|
| 358 |
return (
|
| 359 |
+
<div className="flex flex-col min-h-screen font-sans">
|
| 360 |
<Header />
|
| 361 |
<main className="flex-grow container mx-auto py-12 px-4 md:px-6">
|
| 362 |
<PageHeader />
|
|
|
|
| 369 |
/>
|
| 370 |
</main>
|
| 371 |
<Footer />
|
| 372 |
+
<Toaster />
|
| 373 |
</div>
|
| 374 |
);
|
| 375 |
}
|
examples/cyberpunk-standalone/src/components/VirtualKey.tsx
CHANGED
|
@@ -1,27 +1,70 @@
|
|
| 1 |
-
import { cn } from "@/lib/utils"
|
| 2 |
|
| 3 |
interface VirtualKeyProps {
|
| 4 |
-
label: string
|
| 5 |
-
subLabel?: string
|
| 6 |
-
isPressed?: boolean
|
|
|
|
|
|
|
|
|
|
| 7 |
}
|
| 8 |
|
| 9 |
-
const VirtualKey = ({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
return (
|
| 11 |
<div className="flex flex-col items-center">
|
| 12 |
<div
|
| 13 |
className={cn(
|
| 14 |
"w-12 h-12 border rounded-md flex items-center justify-center font-bold transition-all duration-100",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
isPressed
|
| 16 |
? "bg-primary text-primary-foreground scale-110 border-primary"
|
| 17 |
-
: "bg-black/30 text-muted-foreground border-white/10"
|
| 18 |
)}
|
|
|
|
|
|
|
|
|
|
| 19 |
>
|
| 20 |
{label}
|
| 21 |
</div>
|
| 22 |
-
{subLabel &&
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
</div>
|
| 24 |
-
)
|
| 25 |
-
}
|
| 26 |
|
| 27 |
-
export default VirtualKey
|
|
|
|
| 1 |
+
import { cn } from "@/lib/utils";
|
| 2 |
|
| 3 |
interface VirtualKeyProps {
|
| 4 |
+
label: string;
|
| 5 |
+
subLabel?: string;
|
| 6 |
+
isPressed?: boolean;
|
| 7 |
+
onMouseDown?: () => void;
|
| 8 |
+
onMouseUp?: () => void;
|
| 9 |
+
disabled?: boolean;
|
| 10 |
}
|
| 11 |
|
| 12 |
+
const VirtualKey = ({
|
| 13 |
+
label,
|
| 14 |
+
subLabel,
|
| 15 |
+
isPressed,
|
| 16 |
+
onMouseDown,
|
| 17 |
+
onMouseUp,
|
| 18 |
+
disabled,
|
| 19 |
+
}: VirtualKeyProps) => {
|
| 20 |
+
const handleMouseDown = (e: React.MouseEvent) => {
|
| 21 |
+
e.preventDefault();
|
| 22 |
+
if (!disabled && onMouseDown) {
|
| 23 |
+
onMouseDown();
|
| 24 |
+
}
|
| 25 |
+
};
|
| 26 |
+
|
| 27 |
+
const handleMouseUp = (e: React.MouseEvent) => {
|
| 28 |
+
e.preventDefault();
|
| 29 |
+
if (!disabled && onMouseUp) {
|
| 30 |
+
onMouseUp();
|
| 31 |
+
}
|
| 32 |
+
};
|
| 33 |
+
|
| 34 |
+
const handleMouseLeave = (e: React.MouseEvent) => {
|
| 35 |
+
e.preventDefault();
|
| 36 |
+
if (!disabled && onMouseUp) {
|
| 37 |
+
onMouseUp();
|
| 38 |
+
}
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
return (
|
| 42 |
<div className="flex flex-col items-center">
|
| 43 |
<div
|
| 44 |
className={cn(
|
| 45 |
"w-12 h-12 border rounded-md flex items-center justify-center font-bold transition-all duration-100",
|
| 46 |
+
"select-none user-select-none",
|
| 47 |
+
disabled && "opacity-50 cursor-not-allowed",
|
| 48 |
+
!disabled &&
|
| 49 |
+
(onMouseDown || onMouseUp) &&
|
| 50 |
+
"cursor-pointer hover:bg-white/5",
|
| 51 |
isPressed
|
| 52 |
? "bg-primary text-primary-foreground scale-110 border-primary"
|
| 53 |
+
: "bg-black/30 text-muted-foreground border-white/10"
|
| 54 |
)}
|
| 55 |
+
onMouseDown={handleMouseDown}
|
| 56 |
+
onMouseUp={handleMouseUp}
|
| 57 |
+
onMouseLeave={handleMouseLeave}
|
| 58 |
>
|
| 59 |
{label}
|
| 60 |
</div>
|
| 61 |
+
{subLabel && (
|
| 62 |
+
<span className="text-xs text-muted-foreground mt-1 font-mono">
|
| 63 |
+
{subLabel}
|
| 64 |
+
</span>
|
| 65 |
+
)}
|
| 66 |
</div>
|
| 67 |
+
);
|
| 68 |
+
};
|
| 69 |
|
| 70 |
+
export default VirtualKey;
|
examples/cyberpunk-standalone/src/components/calibration-view.tsx
CHANGED
|
@@ -1,67 +1,227 @@
|
|
| 1 |
-
"use client"
|
| 2 |
-
import { useState, useMemo } from "react"
|
| 3 |
-
import { Download } from "lucide-react"
|
| 4 |
-
import { Button } from "@/components/ui/button"
|
| 5 |
-
import { Card } from "@/components/ui/card"
|
| 6 |
-
import {
|
| 7 |
-
import {
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
interface CalibrationViewProps {
|
| 12 |
-
robot: RobotConnection
|
| 13 |
}
|
| 14 |
|
| 15 |
export function CalibrationView({ robot }: CalibrationViewProps) {
|
| 16 |
-
const [status, setStatus] = useState("Ready to calibrate.")
|
| 17 |
-
const [liveData, setLiveData] = useState<LiveCalibrationData | null>(null)
|
| 18 |
-
const [isCalibrating, setIsCalibrating] = useState(false)
|
| 19 |
-
const [
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
const handleStart = async () => {
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
const handleFinish = async () => {
|
| 38 |
if (calibrationProcess) {
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
}
|
| 45 |
-
}
|
| 46 |
|
| 47 |
const downloadJson = () => {
|
| 48 |
-
if (!calibrationResults) return
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
const motorData = useMemo(
|
| 59 |
() =>
|
| 60 |
liveData
|
| 61 |
? Object.entries(liveData)
|
| 62 |
-
:
|
| 63 |
-
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
return (
|
| 67 |
<Card className="border-0 rounded-none">
|
|
@@ -69,21 +229,32 @@ export function CalibrationView({ robot }: CalibrationViewProps) {
|
|
| 69 |
<div className="flex items-center gap-4">
|
| 70 |
<div className="w-1 h-8 bg-primary"></div>
|
| 71 |
<div>
|
| 72 |
-
<h3 className="text-xl font-bold text-foreground font-mono tracking-wider uppercase">
|
| 73 |
-
|
|
|
|
|
|
|
| 74 |
</div>
|
| 75 |
</div>
|
| 76 |
<div className="flex gap-4">
|
| 77 |
{!isCalibrating ? (
|
| 78 |
-
<Button
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
</Button>
|
| 81 |
) : (
|
| 82 |
<Button onClick={handleFinish} variant="destructive" size="lg">
|
| 83 |
Finish Recording
|
| 84 |
</Button>
|
| 85 |
)}
|
| 86 |
-
<Button
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
<Download className="w-4 h-4 mr-2" /> Download JSON
|
| 88 |
</Button>
|
| 89 |
</div>
|
|
@@ -99,10 +270,21 @@ export function CalibrationView({ robot }: CalibrationViewProps) {
|
|
| 99 |
</div>
|
| 100 |
<div className="border-t border-white/10">
|
| 101 |
{motorData.map(([name, data]) => (
|
| 102 |
-
<MotorCalibrationVisual
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
))}
|
| 104 |
</div>
|
| 105 |
</div>
|
| 106 |
</Card>
|
| 107 |
-
)
|
| 108 |
}
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import { useState, useMemo, useEffect, useCallback } from "react";
|
| 3 |
+
import { Download } from "lucide-react";
|
| 4 |
+
import { Button } from "@/components/ui/button";
|
| 5 |
+
import { Card } from "@/components/ui/card";
|
| 6 |
+
import { useToast } from "@/hooks/use-toast";
|
| 7 |
+
import {
|
| 8 |
+
calibrate,
|
| 9 |
+
releaseMotors,
|
| 10 |
+
type CalibrationProcess,
|
| 11 |
+
type LiveCalibrationData,
|
| 12 |
+
type WebCalibrationResults,
|
| 13 |
+
type RobotConnection,
|
| 14 |
+
} from "@lerobot/web";
|
| 15 |
+
import {
|
| 16 |
+
saveCalibrationData,
|
| 17 |
+
getUnifiedRobotData,
|
| 18 |
+
type CalibrationMetadata,
|
| 19 |
+
} from "@/lib/unified-storage";
|
| 20 |
+
import { MotorCalibrationVisual } from "@/components/motor-calibration-visual";
|
| 21 |
|
| 22 |
interface CalibrationViewProps {
|
| 23 |
+
robot: RobotConnection;
|
| 24 |
}
|
| 25 |
|
| 26 |
export function CalibrationView({ robot }: CalibrationViewProps) {
|
| 27 |
+
const [status, setStatus] = useState("Ready to calibrate.");
|
| 28 |
+
const [liveData, setLiveData] = useState<LiveCalibrationData | null>(null);
|
| 29 |
+
const [isCalibrating, setIsCalibrating] = useState(false);
|
| 30 |
+
const [isPreparing, setIsPreparing] = useState(false);
|
| 31 |
+
const [calibrationProcess, setCalibrationProcess] =
|
| 32 |
+
useState<CalibrationProcess | null>(null);
|
| 33 |
+
const [calibrationResults, setCalibrationResults] =
|
| 34 |
+
useState<WebCalibrationResults | null>(null);
|
| 35 |
+
const { toast } = useToast();
|
| 36 |
+
|
| 37 |
+
// Load existing calibration data from unified storage
|
| 38 |
+
useEffect(() => {
|
| 39 |
+
if (robot.serialNumber) {
|
| 40 |
+
const data = getUnifiedRobotData(robot.serialNumber);
|
| 41 |
+
if (data?.calibration) {
|
| 42 |
+
setCalibrationResults(data.calibration);
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
}, [robot.serialNumber]);
|
| 46 |
+
|
| 47 |
+
// Motor names for display
|
| 48 |
+
const motorNames = useMemo(
|
| 49 |
+
() => [
|
| 50 |
+
"shoulder_pan",
|
| 51 |
+
"shoulder_lift",
|
| 52 |
+
"elbow_flex",
|
| 53 |
+
"wrist_flex",
|
| 54 |
+
"wrist_roll",
|
| 55 |
+
"gripper",
|
| 56 |
+
],
|
| 57 |
+
[]
|
| 58 |
+
);
|
| 59 |
+
|
| 60 |
+
// Release motor torque before calibration
|
| 61 |
+
const releaseMotorTorque = useCallback(async () => {
|
| 62 |
+
try {
|
| 63 |
+
setIsPreparing(true);
|
| 64 |
+
setStatus("🔓 Releasing motor torque - joints can now be moved freely");
|
| 65 |
+
|
| 66 |
+
await releaseMotors(robot);
|
| 67 |
+
|
| 68 |
+
setStatus("✅ Joints are now free to move - ready to start calibration");
|
| 69 |
+
toast({
|
| 70 |
+
title: "Motors Released",
|
| 71 |
+
description: "Robot joints can now be moved freely for calibration",
|
| 72 |
+
});
|
| 73 |
+
} catch (error) {
|
| 74 |
+
console.error("Failed to release motor torque:", error);
|
| 75 |
+
setStatus("⚠️ Could not release motor torque - try moving joints gently");
|
| 76 |
+
toast({
|
| 77 |
+
title: "Motor Release Warning",
|
| 78 |
+
description:
|
| 79 |
+
"Could not release motor torque. Try moving joints gently.",
|
| 80 |
+
variant: "destructive",
|
| 81 |
+
});
|
| 82 |
+
} finally {
|
| 83 |
+
setIsPreparing(false);
|
| 84 |
+
}
|
| 85 |
+
}, [robot, toast]);
|
| 86 |
|
| 87 |
const handleStart = async () => {
|
| 88 |
+
try {
|
| 89 |
+
setIsCalibrating(true);
|
| 90 |
+
setStatus("🤖 Starting calibration process...");
|
| 91 |
+
|
| 92 |
+
// Release motors first
|
| 93 |
+
await releaseMotorTorque();
|
| 94 |
+
|
| 95 |
+
// Start calibration process
|
| 96 |
+
const process = await calibrate({
|
| 97 |
+
robot,
|
| 98 |
+
onLiveUpdate: (data) => {
|
| 99 |
+
setLiveData(data);
|
| 100 |
+
setStatus(
|
| 101 |
+
"📏 Recording joint ranges - move all joints through their full range"
|
| 102 |
+
);
|
| 103 |
+
},
|
| 104 |
+
onProgress: (message) => {
|
| 105 |
+
setStatus(message);
|
| 106 |
+
},
|
| 107 |
+
});
|
| 108 |
+
|
| 109 |
+
setCalibrationProcess(process);
|
| 110 |
+
|
| 111 |
+
// Add Enter key listener for stopping (matching Node.js UX)
|
| 112 |
+
const handleKeyPress = (event: KeyboardEvent) => {
|
| 113 |
+
if (event.key === "Enter") {
|
| 114 |
+
process.stop();
|
| 115 |
+
}
|
| 116 |
+
};
|
| 117 |
+
document.addEventListener("keydown", handleKeyPress);
|
| 118 |
+
|
| 119 |
+
try {
|
| 120 |
+
// Wait for calibration to complete
|
| 121 |
+
const result = await process.result;
|
| 122 |
+
setCalibrationResults(result);
|
| 123 |
+
|
| 124 |
+
// Save results to unified storage
|
| 125 |
+
if (robot.serialNumber) {
|
| 126 |
+
const metadata: CalibrationMetadata = {
|
| 127 |
+
timestamp: new Date().toISOString(),
|
| 128 |
+
readCount: Object.keys(liveData || {}).length > 0 ? 100 : 0,
|
| 129 |
+
};
|
| 130 |
+
|
| 131 |
+
// Use the result directly as WebCalibrationResults
|
| 132 |
+
saveCalibrationData(robot.serialNumber, result, metadata);
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
setStatus(
|
| 136 |
+
"✅ Calibration completed successfully! Configuration saved."
|
| 137 |
+
);
|
| 138 |
+
toast({
|
| 139 |
+
title: "Calibration Complete",
|
| 140 |
+
description: "Robot calibration has been saved successfully",
|
| 141 |
+
});
|
| 142 |
+
} finally {
|
| 143 |
+
document.removeEventListener("keydown", handleKeyPress);
|
| 144 |
+
setCalibrationProcess(null);
|
| 145 |
+
setIsCalibrating(false);
|
| 146 |
+
}
|
| 147 |
+
} catch (error) {
|
| 148 |
+
console.error("Calibration failed:", error);
|
| 149 |
+
setStatus(
|
| 150 |
+
`❌ Calibration failed: ${
|
| 151 |
+
error instanceof Error ? error.message : error
|
| 152 |
+
}`
|
| 153 |
+
);
|
| 154 |
+
toast({
|
| 155 |
+
title: "Calibration Failed",
|
| 156 |
+
description:
|
| 157 |
+
error instanceof Error ? error.message : "An unknown error occurred",
|
| 158 |
+
variant: "destructive",
|
| 159 |
+
});
|
| 160 |
+
setIsCalibrating(false);
|
| 161 |
+
setCalibrationProcess(null);
|
| 162 |
+
}
|
| 163 |
+
};
|
| 164 |
|
| 165 |
const handleFinish = async () => {
|
| 166 |
if (calibrationProcess) {
|
| 167 |
+
try {
|
| 168 |
+
calibrationProcess.stop();
|
| 169 |
+
toast({
|
| 170 |
+
title: "Calibration Stopped",
|
| 171 |
+
description: "Calibration recording has been stopped",
|
| 172 |
+
});
|
| 173 |
+
} catch (error) {
|
| 174 |
+
console.error("Failed to stop calibration:", error);
|
| 175 |
+
toast({
|
| 176 |
+
title: "Stop Error",
|
| 177 |
+
description: "Failed to stop calibration cleanly",
|
| 178 |
+
variant: "destructive",
|
| 179 |
+
});
|
| 180 |
+
}
|
| 181 |
}
|
| 182 |
+
};
|
| 183 |
|
| 184 |
const downloadJson = () => {
|
| 185 |
+
if (!calibrationResults) return;
|
| 186 |
+
|
| 187 |
+
try {
|
| 188 |
+
const dataStr =
|
| 189 |
+
"data:text/json;charset=utf-8," +
|
| 190 |
+
encodeURIComponent(JSON.stringify(calibrationResults, null, 2));
|
| 191 |
+
const downloadAnchorNode = document.createElement("a");
|
| 192 |
+
downloadAnchorNode.setAttribute("href", dataStr);
|
| 193 |
+
downloadAnchorNode.setAttribute(
|
| 194 |
+
"download",
|
| 195 |
+
`${robot.robotId}_calibration.json`
|
| 196 |
+
);
|
| 197 |
+
document.body.appendChild(downloadAnchorNode);
|
| 198 |
+
downloadAnchorNode.click();
|
| 199 |
+
downloadAnchorNode.remove();
|
| 200 |
+
|
| 201 |
+
toast({
|
| 202 |
+
title: "Download Started",
|
| 203 |
+
description: "Calibration file download has started",
|
| 204 |
+
});
|
| 205 |
+
} catch (error) {
|
| 206 |
+
console.error("Failed to download calibration file:", error);
|
| 207 |
+
toast({
|
| 208 |
+
title: "Download Error",
|
| 209 |
+
description: "Failed to download calibration file",
|
| 210 |
+
variant: "destructive",
|
| 211 |
+
});
|
| 212 |
+
}
|
| 213 |
+
};
|
| 214 |
|
| 215 |
const motorData = useMemo(
|
| 216 |
() =>
|
| 217 |
liveData
|
| 218 |
? Object.entries(liveData)
|
| 219 |
+
: motorNames.map((name) => [
|
| 220 |
+
name,
|
| 221 |
+
{ current: 0, min: 4095, max: 0, range: 0 },
|
| 222 |
+
]),
|
| 223 |
+
[liveData, motorNames]
|
| 224 |
+
);
|
| 225 |
|
| 226 |
return (
|
| 227 |
<Card className="border-0 rounded-none">
|
|
|
|
| 229 |
<div className="flex items-center gap-4">
|
| 230 |
<div className="w-1 h-8 bg-primary"></div>
|
| 231 |
<div>
|
| 232 |
+
<h3 className="text-xl font-bold text-foreground font-mono tracking-wider uppercase">
|
| 233 |
+
motor calibration
|
| 234 |
+
</h3>
|
| 235 |
+
<p className="text-sm text-muted-foreground font-mono">{status}</p>
|
| 236 |
</div>
|
| 237 |
</div>
|
| 238 |
<div className="flex gap-4">
|
| 239 |
{!isCalibrating ? (
|
| 240 |
+
<Button
|
| 241 |
+
onClick={handleStart}
|
| 242 |
+
size="lg"
|
| 243 |
+
disabled={isPreparing || !robot.isConnected}
|
| 244 |
+
>
|
| 245 |
+
{isPreparing ? "Preparing..." : "Start Calibration"}
|
| 246 |
</Button>
|
| 247 |
) : (
|
| 248 |
<Button onClick={handleFinish} variant="destructive" size="lg">
|
| 249 |
Finish Recording
|
| 250 |
</Button>
|
| 251 |
)}
|
| 252 |
+
<Button
|
| 253 |
+
onClick={downloadJson}
|
| 254 |
+
variant="outline"
|
| 255 |
+
size="lg"
|
| 256 |
+
disabled={!calibrationResults}
|
| 257 |
+
>
|
| 258 |
<Download className="w-4 h-4 mr-2" /> Download JSON
|
| 259 |
</Button>
|
| 260 |
</div>
|
|
|
|
| 270 |
</div>
|
| 271 |
<div className="border-t border-white/10">
|
| 272 |
{motorData.map(([name, data]) => (
|
| 273 |
+
<MotorCalibrationVisual
|
| 274 |
+
key={name as string}
|
| 275 |
+
name={name as string}
|
| 276 |
+
data={
|
| 277 |
+
data as {
|
| 278 |
+
current: number;
|
| 279 |
+
min: number;
|
| 280 |
+
max: number;
|
| 281 |
+
range: number;
|
| 282 |
+
}
|
| 283 |
+
}
|
| 284 |
+
/>
|
| 285 |
))}
|
| 286 |
</div>
|
| 287 |
</div>
|
| 288 |
</Card>
|
| 289 |
+
);
|
| 290 |
}
|
examples/cyberpunk-standalone/src/components/device-dashboard.tsx
CHANGED
|
@@ -1,22 +1,29 @@
|
|
| 1 |
-
"use client"
|
| 2 |
|
| 3 |
-
import {
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
interface DeviceDashboardProps {
|
| 12 |
-
robots: RobotConnection[]
|
| 13 |
-
onCalibrate: (robot: RobotConnection) => void
|
| 14 |
-
onTeleoperate: (robot: RobotConnection) => void
|
| 15 |
-
onRemove: (robotId: string) => void
|
| 16 |
-
onEdit: (robot: RobotConnection) => void
|
| 17 |
-
onFindNew: () => void
|
| 18 |
-
isConnecting: boolean
|
| 19 |
-
onScrollToHardware: () => void
|
| 20 |
}
|
| 21 |
|
| 22 |
export function DeviceDashboard({
|
|
@@ -35,9 +42,13 @@ export function DeviceDashboard({
|
|
| 35 |
<div className="flex items-center gap-4">
|
| 36 |
<div className="w-1 h-8 bg-primary"></div>
|
| 37 |
<div>
|
| 38 |
-
<h3 className="text-xl font-bold text-foreground font-mono tracking-wider uppercase">
|
|
|
|
|
|
|
| 39 |
<div className="flex items-center gap-2 mt-1">
|
| 40 |
-
<span className="text-xs text-muted-foreground font-mono">
|
|
|
|
|
|
|
| 41 |
<button
|
| 42 |
onClick={onScrollToHardware}
|
| 43 |
className="text-xs text-primary hover:text-accent transition-colors underline font-mono flex items-center gap-1"
|
|
@@ -49,7 +60,12 @@ export function DeviceDashboard({
|
|
| 49 |
</div>
|
| 50 |
</div>
|
| 51 |
{robots.length > 0 && (
|
| 52 |
-
<Button
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
<Plus className="w-4 h-4 mr-2" />
|
| 54 |
add unit
|
| 55 |
</Button>
|
|
@@ -67,17 +83,27 @@ export function DeviceDashboard({
|
|
| 67 |
<div className="w-16 h-16 mx-auto mb-4 border-2 border-primary/50 rounded-lg flex items-center justify-center animate-pulse">
|
| 68 |
<Plus className="w-8 h-8 text-primary animate-spin" />
|
| 69 |
</div>
|
| 70 |
-
<h4 className="text-xl text-primary mb-2 tracking-wider uppercase">
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
</>
|
| 73 |
) : (
|
| 74 |
<>
|
| 75 |
<div className="w-16 h-16 mx-auto mb-4 border-2 border-dashed border-primary/50 rounded-lg flex items-center justify-center">
|
| 76 |
<Plus className="w-8 h-8 text-primary/50" />
|
| 77 |
</div>
|
| 78 |
-
<h4 className="text-xl text-primary mb-2 tracking-wider uppercase">
|
|
|
|
|
|
|
| 79 |
|
| 80 |
-
<Button
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
<Plus className="w-4 h-4 mr-2" />
|
| 82 |
add unit
|
| 83 |
</Button>
|
|
@@ -95,18 +121,26 @@ export function DeviceDashboard({
|
|
| 95 |
<div className="p-4 border-b border-white/10">
|
| 96 |
<div className="flex items-start justify-between mb-3">
|
| 97 |
<div className="flex-1">
|
| 98 |
-
<h4 className="text-xl font-bold text-primary font-mono tracking-wider">
|
|
|
|
|
|
|
| 99 |
<div className="flex items-center gap-2 mt-1">
|
| 100 |
-
<span className="text-xs text-muted-foreground font-mono">
|
| 101 |
-
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
</div>
|
| 104 |
</div>
|
| 105 |
<Badge
|
| 106 |
variant="outline"
|
| 107 |
className={cn(
|
| 108 |
"border-primary/50 bg-primary/20 text-primary font-mono text-xs",
|
| 109 |
-
robot.isConnected && "animate-pulse-slow"
|
| 110 |
)}
|
| 111 |
>
|
| 112 |
{robot.isConnected ? "ONLINE" : "OFFLINE"}
|
|
@@ -117,14 +151,20 @@ export function DeviceDashboard({
|
|
| 117 |
<div className="flex-grow p-4">
|
| 118 |
<div className="grid grid-cols-2 gap-4 text-xs font-mono">
|
| 119 |
<div>
|
| 120 |
-
<span className="text-muted-foreground uppercase">
|
|
|
|
|
|
|
| 121 |
<div className="text-primary uppercase">
|
| 122 |
{robot.isConnected ? "operational" : "disconnected"}
|
| 123 |
</div>
|
| 124 |
</div>
|
| 125 |
<div>
|
| 126 |
-
<span className="text-muted-foreground uppercase">
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
</div>
|
| 129 |
</div>
|
| 130 |
</div>
|
|
@@ -157,7 +197,11 @@ export function DeviceDashboard({
|
|
| 157 |
<Button
|
| 158 |
variant="destructive"
|
| 159 |
size="sm"
|
| 160 |
-
onClick={() =>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
className="font-mono text-xs uppercase"
|
| 162 |
>
|
| 163 |
<Trash2 className="w-3 h-3 mr-1" /> remove
|
|
@@ -170,5 +214,5 @@ export function DeviceDashboard({
|
|
| 170 |
)}
|
| 171 |
</div>
|
| 172 |
</Card>
|
| 173 |
-
)
|
| 174 |
}
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
|
| 3 |
+
import {
|
| 4 |
+
Settings,
|
| 5 |
+
Gamepad2,
|
| 6 |
+
Trash2,
|
| 7 |
+
Pencil,
|
| 8 |
+
Plus,
|
| 9 |
+
ExternalLink,
|
| 10 |
+
} from "lucide-react";
|
| 11 |
+
import { Button } from "@/components/ui/button";
|
| 12 |
+
import { Card, CardFooter } from "@/components/ui/card";
|
| 13 |
+
import { Badge } from "@/components/ui/badge";
|
| 14 |
+
import { cn } from "@/lib/utils";
|
| 15 |
+
import HudCorners from "@/components/hud-corners";
|
| 16 |
+
import type { RobotConnection } from "@/types/robot";
|
| 17 |
|
| 18 |
interface DeviceDashboardProps {
|
| 19 |
+
robots: RobotConnection[];
|
| 20 |
+
onCalibrate: (robot: RobotConnection) => void;
|
| 21 |
+
onTeleoperate: (robot: RobotConnection) => void;
|
| 22 |
+
onRemove: (robotId: string) => void;
|
| 23 |
+
onEdit: (robot: RobotConnection) => void;
|
| 24 |
+
onFindNew: () => void;
|
| 25 |
+
isConnecting: boolean;
|
| 26 |
+
onScrollToHardware: () => void;
|
| 27 |
}
|
| 28 |
|
| 29 |
export function DeviceDashboard({
|
|
|
|
| 42 |
<div className="flex items-center gap-4">
|
| 43 |
<div className="w-1 h-8 bg-primary"></div>
|
| 44 |
<div>
|
| 45 |
+
<h3 className="text-xl font-bold text-foreground font-mono tracking-wider uppercase">
|
| 46 |
+
device registry
|
| 47 |
+
</h3>
|
| 48 |
<div className="flex items-center gap-2 mt-1">
|
| 49 |
+
<span className="text-xs text-muted-foreground font-mono">
|
| 50 |
+
currently supports SO-100{" "}
|
| 51 |
+
</span>
|
| 52 |
<button
|
| 53 |
onClick={onScrollToHardware}
|
| 54 |
className="text-xs text-primary hover:text-accent transition-colors underline font-mono flex items-center gap-1"
|
|
|
|
| 60 |
</div>
|
| 61 |
</div>
|
| 62 |
{robots.length > 0 && (
|
| 63 |
+
<Button
|
| 64 |
+
onClick={onFindNew}
|
| 65 |
+
disabled={isConnecting}
|
| 66 |
+
size="lg"
|
| 67 |
+
className="font-mono uppercase"
|
| 68 |
+
>
|
| 69 |
<Plus className="w-4 h-4 mr-2" />
|
| 70 |
add unit
|
| 71 |
</Button>
|
|
|
|
| 83 |
<div className="w-16 h-16 mx-auto mb-4 border-2 border-primary/50 rounded-lg flex items-center justify-center animate-pulse">
|
| 84 |
<Plus className="w-8 h-8 text-primary animate-spin" />
|
| 85 |
</div>
|
| 86 |
+
<h4 className="text-xl text-primary mb-2 tracking-wider uppercase">
|
| 87 |
+
scanning for units
|
| 88 |
+
</h4>
|
| 89 |
+
<p className="text-sm text-muted-foreground mb-8">
|
| 90 |
+
searching for available devices...
|
| 91 |
+
</p>
|
| 92 |
</>
|
| 93 |
) : (
|
| 94 |
<>
|
| 95 |
<div className="w-16 h-16 mx-auto mb-4 border-2 border-dashed border-primary/50 rounded-lg flex items-center justify-center">
|
| 96 |
<Plus className="w-8 h-8 text-primary/50" />
|
| 97 |
</div>
|
| 98 |
+
<h4 className="text-xl text-primary mb-2 tracking-wider uppercase">
|
| 99 |
+
no units detected
|
| 100 |
+
</h4>
|
| 101 |
|
| 102 |
+
<Button
|
| 103 |
+
onClick={onFindNew}
|
| 104 |
+
size="lg"
|
| 105 |
+
className="font-mono uppercase"
|
| 106 |
+
>
|
| 107 |
<Plus className="w-4 h-4 mr-2" />
|
| 108 |
add unit
|
| 109 |
</Button>
|
|
|
|
| 121 |
<div className="p-4 border-b border-white/10">
|
| 122 |
<div className="flex items-start justify-between mb-3">
|
| 123 |
<div className="flex-1">
|
| 124 |
+
<h4 className="text-xl font-bold text-primary font-mono tracking-wider">
|
| 125 |
+
{robot.name}
|
| 126 |
+
</h4>
|
| 127 |
<div className="flex items-center gap-2 mt-1">
|
| 128 |
+
<span className="text-xs text-muted-foreground font-mono">
|
| 129 |
+
{robot.serialNumber}
|
| 130 |
+
</span>
|
| 131 |
+
<span className="text-xs text-muted-foreground">
|
| 132 |
+
•
|
| 133 |
+
</span>
|
| 134 |
+
<span className="text-xs font-mono uppercase text-muted-foreground">
|
| 135 |
+
{robot.robotType}
|
| 136 |
+
</span>
|
| 137 |
</div>
|
| 138 |
</div>
|
| 139 |
<Badge
|
| 140 |
variant="outline"
|
| 141 |
className={cn(
|
| 142 |
"border-primary/50 bg-primary/20 text-primary font-mono text-xs",
|
| 143 |
+
robot.isConnected && "animate-pulse-slow"
|
| 144 |
)}
|
| 145 |
>
|
| 146 |
{robot.isConnected ? "ONLINE" : "OFFLINE"}
|
|
|
|
| 151 |
<div className="flex-grow p-4">
|
| 152 |
<div className="grid grid-cols-2 gap-4 text-xs font-mono">
|
| 153 |
<div>
|
| 154 |
+
<span className="text-muted-foreground uppercase">
|
| 155 |
+
status:
|
| 156 |
+
</span>
|
| 157 |
<div className="text-primary uppercase">
|
| 158 |
{robot.isConnected ? "operational" : "disconnected"}
|
| 159 |
</div>
|
| 160 |
</div>
|
| 161 |
<div>
|
| 162 |
+
<span className="text-muted-foreground uppercase">
|
| 163 |
+
type:
|
| 164 |
+
</span>
|
| 165 |
+
<div className="text-accent uppercase">
|
| 166 |
+
{robot.robotType}
|
| 167 |
+
</div>
|
| 168 |
</div>
|
| 169 |
</div>
|
| 170 |
</div>
|
|
|
|
| 197 |
<Button
|
| 198 |
variant="destructive"
|
| 199 |
size="sm"
|
| 200 |
+
onClick={() =>
|
| 201 |
+
onRemove(
|
| 202 |
+
robot.robotId || robot.serialNumber || "unknown"
|
| 203 |
+
)
|
| 204 |
+
}
|
| 205 |
className="font-mono text-xs uppercase"
|
| 206 |
>
|
| 207 |
<Trash2 className="w-3 h-3 mr-1" /> remove
|
|
|
|
| 214 |
)}
|
| 215 |
</div>
|
| 216 |
</Card>
|
| 217 |
+
);
|
| 218 |
}
|
examples/cyberpunk-standalone/src/components/edit-robot-dialog.tsx
CHANGED
|
@@ -1,37 +1,51 @@
|
|
| 1 |
-
"use client"
|
| 2 |
-
import { useState, useEffect } from "react"
|
| 3 |
-
import { Button } from "@/components/ui/button"
|
| 4 |
-
import {
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
interface EditRobotDialogProps {
|
| 11 |
-
robot: RobotConnection | null
|
| 12 |
-
isOpen: boolean
|
| 13 |
-
onOpenChange: (open: boolean) => void
|
| 14 |
-
onSave: (updatedRobot: RobotConnection) => void
|
| 15 |
}
|
| 16 |
|
| 17 |
-
export function EditRobotDialog({
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
useEffect(() => {
|
| 22 |
if (robot) {
|
| 23 |
-
setName(robot.name)
|
| 24 |
-
setType(robot.robotType || "so100_follower")
|
| 25 |
}
|
| 26 |
-
}, [robot])
|
| 27 |
|
| 28 |
const handleSave = () => {
|
| 29 |
if (robot) {
|
| 30 |
-
onSave({ ...robot, name, robotType: type })
|
| 31 |
}
|
| 32 |
-
}
|
| 33 |
|
| 34 |
-
if (!robot) return null
|
| 35 |
|
| 36 |
return (
|
| 37 |
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
|
@@ -44,11 +58,22 @@ export function EditRobotDialog({ robot, isOpen, onOpenChange, onSave }: EditRob
|
|
| 44 |
<Label htmlFor="name" className="text-right">
|
| 45 |
Name
|
| 46 |
</Label>
|
| 47 |
-
<Input
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
</div>
|
| 49 |
<div className="grid grid-cols-4 items-center gap-4">
|
| 50 |
<Label className="text-right">Type</Label>
|
| 51 |
-
<RadioGroup
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
<div className="flex items-center space-x-2">
|
| 53 |
<RadioGroupItem value="so100_follower" id="r1" />
|
| 54 |
<Label htmlFor="r1">SO-100 Follower</Label>
|
|
@@ -74,5 +99,5 @@ export function EditRobotDialog({ robot, isOpen, onOpenChange, onSave }: EditRob
|
|
| 74 |
</DialogFooter>
|
| 75 |
</DialogContent>
|
| 76 |
</Dialog>
|
| 77 |
-
)
|
| 78 |
}
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import { useState, useEffect } from "react";
|
| 3 |
+
import { Button } from "@/components/ui/button";
|
| 4 |
+
import {
|
| 5 |
+
Dialog,
|
| 6 |
+
DialogContent,
|
| 7 |
+
DialogHeader,
|
| 8 |
+
DialogTitle,
|
| 9 |
+
DialogFooter,
|
| 10 |
+
DialogClose,
|
| 11 |
+
} from "@/components/ui/dialog";
|
| 12 |
+
import { Input } from "@/components/ui/input";
|
| 13 |
+
import { Label } from "@/components/ui/label";
|
| 14 |
+
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
| 15 |
+
import type { RobotConnection } from "@/types/robot";
|
| 16 |
|
| 17 |
interface EditRobotDialogProps {
|
| 18 |
+
robot: RobotConnection | null;
|
| 19 |
+
isOpen: boolean;
|
| 20 |
+
onOpenChange: (open: boolean) => void;
|
| 21 |
+
onSave: (updatedRobot: RobotConnection) => void;
|
| 22 |
}
|
| 23 |
|
| 24 |
+
export function EditRobotDialog({
|
| 25 |
+
robot,
|
| 26 |
+
isOpen,
|
| 27 |
+
onOpenChange,
|
| 28 |
+
onSave,
|
| 29 |
+
}: EditRobotDialogProps) {
|
| 30 |
+
const [name, setName] = useState("");
|
| 31 |
+
const [type, setType] = useState<"so100_follower" | "so100_leader">(
|
| 32 |
+
"so100_follower"
|
| 33 |
+
);
|
| 34 |
|
| 35 |
useEffect(() => {
|
| 36 |
if (robot) {
|
| 37 |
+
setName(robot.name);
|
| 38 |
+
setType(robot.robotType || "so100_follower");
|
| 39 |
}
|
| 40 |
+
}, [robot]);
|
| 41 |
|
| 42 |
const handleSave = () => {
|
| 43 |
if (robot) {
|
| 44 |
+
onSave({ ...robot, name, robotId: name, robotType: type });
|
| 45 |
}
|
| 46 |
+
};
|
| 47 |
|
| 48 |
+
if (!robot) return null;
|
| 49 |
|
| 50 |
return (
|
| 51 |
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
|
|
|
| 58 |
<Label htmlFor="name" className="text-right">
|
| 59 |
Name
|
| 60 |
</Label>
|
| 61 |
+
<Input
|
| 62 |
+
id="name"
|
| 63 |
+
value={name}
|
| 64 |
+
onChange={(e) => setName(e.target.value)}
|
| 65 |
+
className="col-span-3 font-mono"
|
| 66 |
+
/>
|
| 67 |
</div>
|
| 68 |
<div className="grid grid-cols-4 items-center gap-4">
|
| 69 |
<Label className="text-right">Type</Label>
|
| 70 |
+
<RadioGroup
|
| 71 |
+
value={type}
|
| 72 |
+
onValueChange={(value) =>
|
| 73 |
+
setType(value as "so100_follower" | "so100_leader")
|
| 74 |
+
}
|
| 75 |
+
className="col-span-3"
|
| 76 |
+
>
|
| 77 |
<div className="flex items-center space-x-2">
|
| 78 |
<RadioGroupItem value="so100_follower" id="r1" />
|
| 79 |
<Label htmlFor="r1">SO-100 Follower</Label>
|
|
|
|
| 99 |
</DialogFooter>
|
| 100 |
</DialogContent>
|
| 101 |
</Dialog>
|
| 102 |
+
);
|
| 103 |
}
|
examples/cyberpunk-standalone/src/components/teleoperation-view.tsx
CHANGED
|
@@ -1,73 +1,396 @@
|
|
| 1 |
-
"use client"
|
| 2 |
-
import { useState, useEffect, useMemo } from "react"
|
| 3 |
-
import { Power, PowerOff, Keyboard } from "lucide-react"
|
| 4 |
-
import { Button } from "@/components/ui/button"
|
| 5 |
-
import { Card } from "@/components/ui/card"
|
| 6 |
-
import { Badge } from "@/components/ui/badge"
|
| 7 |
-
import { Slider } from "@/components/ui/slider"
|
| 8 |
-
import {
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
interface TeleoperationViewProps {
|
| 16 |
-
robot: RobotConnection
|
| 17 |
}
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
export function TeleoperationView({ robot }: TeleoperationViewProps) {
|
| 20 |
-
const [
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
|
|
|
| 29 |
const calibrationData = useMemo(() => {
|
| 30 |
-
if (
|
| 31 |
-
return lerobot.MOCK_MOTOR_NAMES.reduce((acc, name) => {
|
| 32 |
-
acc[name] = { min: 1000, max: 3000 }
|
| 33 |
-
return acc
|
| 34 |
-
}, {} as WebCalibrationResults)
|
| 35 |
-
}, [localCalibrationData])
|
| 36 |
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
process = await lerobot.teleoperate(robot, {
|
| 41 |
-
calibrationData,
|
| 42 |
-
onStateUpdate: setTeleopState,
|
| 43 |
-
})
|
| 44 |
-
setTeleopProcess(process)
|
| 45 |
}
|
| 46 |
-
setup()
|
| 47 |
|
| 48 |
-
|
| 49 |
-
|
|
|
|
| 50 |
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
return () => {
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
}
|
| 59 |
-
}
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
return (
|
| 73 |
<Card className="border-0 rounded-none">
|
|
@@ -76,30 +399,40 @@ export function TeleoperationView({ robot }: TeleoperationViewProps) {
|
|
| 76 |
<div className="flex items-center gap-4">
|
| 77 |
<div className="w-1 h-8 bg-primary"></div>
|
| 78 |
<div>
|
| 79 |
-
<h3 className="text-xl font-bold text-foreground font-mono tracking-wider uppercase">
|
|
|
|
|
|
|
| 80 |
<p className="text-sm text-muted-foreground font-mono">
|
| 81 |
-
manual
|
|
|
|
|
|
|
| 82 |
</p>
|
| 83 |
</div>
|
| 84 |
</div>
|
| 85 |
<div className="flex items-center gap-6">
|
| 86 |
<div className="border-l border-white/10 pl-6 flex items-center gap-4">
|
| 87 |
{teleopState?.isActive ? (
|
| 88 |
-
<Button onClick={
|
| 89 |
<PowerOff className="w-5 h-5 mr-2" /> Stop Control
|
| 90 |
</Button>
|
| 91 |
) : (
|
| 92 |
-
<Button
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
<Power className="w-5 h-5 mr-2" /> Control Robot
|
| 94 |
</Button>
|
| 95 |
)}
|
| 96 |
<div className="flex items-center gap-2">
|
| 97 |
-
<span className="text-sm font-mono text-muted-foreground uppercase">
|
|
|
|
|
|
|
| 98 |
<Badge
|
| 99 |
variant="outline"
|
| 100 |
className={cn(
|
| 101 |
"border-primary/50 bg-primary/20 text-primary font-mono text-xs",
|
| 102 |
-
teleopState?.isActive && "animate-pulse-slow"
|
| 103 |
)}
|
| 104 |
>
|
| 105 |
{teleopState?.isActive ? "ACTIVE" : "STOPPED"}
|
|
@@ -111,21 +444,33 @@ export function TeleoperationView({ robot }: TeleoperationViewProps) {
|
|
| 111 |
</div>
|
| 112 |
<div className="pt-6 p-6 grid md:grid-cols-2 gap-8">
|
| 113 |
<div>
|
| 114 |
-
<h3 className="font-sans font-semibold mb-4 text-xl">
|
|
|
|
|
|
|
| 115 |
<div className="space-y-6">
|
| 116 |
{motorConfigs.map((motor) => (
|
| 117 |
<div key={motor.name}>
|
| 118 |
-
<label className="text-sm font-mono text-muted-foreground">
|
|
|
|
|
|
|
| 119 |
<div className="flex items-center gap-4">
|
| 120 |
<Slider
|
| 121 |
value={[motor.currentPosition]}
|
| 122 |
min={motor.minPosition}
|
| 123 |
max={motor.maxPosition}
|
| 124 |
step={1}
|
| 125 |
-
onValueChange={(val) =>
|
| 126 |
disabled={!teleopState?.isActive}
|
|
|
|
| 127 |
/>
|
| 128 |
-
<span
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
{Math.round(motor.currentPosition)}
|
| 130 |
</span>
|
| 131 |
</div>
|
|
@@ -134,30 +479,68 @@ export function TeleoperationView({ robot }: TeleoperationViewProps) {
|
|
| 134 |
</div>
|
| 135 |
</div>
|
| 136 |
<div>
|
| 137 |
-
<h3 className="font-sans font-semibold mb-4 text-xl">
|
|
|
|
|
|
|
| 138 |
<div className="p-4 bg-black/30 rounded-lg space-y-4">
|
| 139 |
<div className="flex justify-around items-end">
|
| 140 |
<div className="flex flex-col items-center gap-2">
|
| 141 |
<VirtualKey
|
| 142 |
label="↑"
|
| 143 |
subLabel="Lift+"
|
| 144 |
-
isPressed={
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
/>
|
| 146 |
<div className="flex gap-2">
|
| 147 |
<VirtualKey
|
| 148 |
label="←"
|
| 149 |
subLabel="Pan-"
|
| 150 |
-
isPressed={
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
/>
|
| 152 |
<VirtualKey
|
| 153 |
label="↓"
|
| 154 |
subLabel="Lift-"
|
| 155 |
-
isPressed={
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
/>
|
| 157 |
<VirtualKey
|
| 158 |
label="→"
|
| 159 |
subLabel="Pan+"
|
| 160 |
-
isPressed={
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
/>
|
| 162 |
</div>
|
| 163 |
<span className="font-bold text-sm font-sans">Shoulder</span>
|
|
@@ -167,22 +550,56 @@ export function TeleoperationView({ robot }: TeleoperationViewProps) {
|
|
| 167 |
label="W"
|
| 168 |
subLabel="Elbow+"
|
| 169 |
isPressed={!!keyStates[controls.elbow_flex.positive]?.pressed}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
/>
|
| 171 |
<div className="flex gap-2">
|
| 172 |
<VirtualKey
|
| 173 |
label="A"
|
| 174 |
subLabel="Wrist+"
|
| 175 |
-
isPressed={
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
/>
|
| 177 |
<VirtualKey
|
| 178 |
label="S"
|
| 179 |
subLabel="Elbow-"
|
| 180 |
-
isPressed={
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
/>
|
| 182 |
<VirtualKey
|
| 183 |
label="D"
|
| 184 |
subLabel="Wrist-"
|
| 185 |
-
isPressed={
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
/>
|
| 187 |
</div>
|
| 188 |
<span className="font-bold text-sm font-sans">Elbow/Wrist</span>
|
|
@@ -192,17 +609,57 @@ export function TeleoperationView({ robot }: TeleoperationViewProps) {
|
|
| 192 |
<VirtualKey
|
| 193 |
label="Q"
|
| 194 |
subLabel="Roll+"
|
| 195 |
-
isPressed={
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
/>
|
| 197 |
<VirtualKey
|
| 198 |
label="E"
|
| 199 |
subLabel="Roll-"
|
| 200 |
-
isPressed={
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
/>
|
| 202 |
</div>
|
| 203 |
<div className="flex gap-2">
|
| 204 |
-
<VirtualKey
|
| 205 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
</div>
|
| 207 |
<span className="font-bold text-sm font-sans">Roll/Grip</span>
|
| 208 |
</div>
|
|
@@ -211,7 +668,10 @@ export function TeleoperationView({ robot }: TeleoperationViewProps) {
|
|
| 211 |
<div className="flex justify-between items-center font-mono text-sm">
|
| 212 |
<div className="flex items-center gap-2 text-muted-foreground">
|
| 213 |
<Keyboard className="w-4 h-4" />
|
| 214 |
-
<span>
|
|
|
|
|
|
|
|
|
|
| 215 |
</div>
|
| 216 |
<TooltipProvider>
|
| 217 |
<Tooltip>
|
|
@@ -219,10 +679,33 @@ export function TeleoperationView({ robot }: TeleoperationViewProps) {
|
|
| 219 |
<div
|
| 220 |
className={cn(
|
| 221 |
"w-10 h-6 border rounded-md flex items-center justify-center font-mono text-xs transition-all",
|
| 222 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
? "bg-destructive text-destructive-foreground border-destructive"
|
| 224 |
-
: "bg-background"
|
| 225 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
>
|
| 227 |
ESC
|
| 228 |
</div>
|
|
@@ -236,5 +719,5 @@ export function TeleoperationView({ robot }: TeleoperationViewProps) {
|
|
| 236 |
</div>
|
| 237 |
</div>
|
| 238 |
</Card>
|
| 239 |
-
)
|
| 240 |
}
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
|
| 3 |
+
import { Power, PowerOff, Keyboard } from "lucide-react";
|
| 4 |
+
import { Button } from "@/components/ui/button";
|
| 5 |
+
import { Card } from "@/components/ui/card";
|
| 6 |
+
import { Badge } from "@/components/ui/badge";
|
| 7 |
+
import { Slider } from "@/components/ui/slider";
|
| 8 |
+
import {
|
| 9 |
+
Tooltip,
|
| 10 |
+
TooltipContent,
|
| 11 |
+
TooltipProvider,
|
| 12 |
+
TooltipTrigger,
|
| 13 |
+
} from "@/components/ui/tooltip";
|
| 14 |
+
import { cn } from "@/lib/utils";
|
| 15 |
+
import { useToast } from "@/hooks/use-toast";
|
| 16 |
+
import {
|
| 17 |
+
teleoperate,
|
| 18 |
+
type TeleoperationProcess,
|
| 19 |
+
type TeleoperationState,
|
| 20 |
+
type TeleoperateConfig,
|
| 21 |
+
type RobotConnection,
|
| 22 |
+
} from "@lerobot/web";
|
| 23 |
+
import { getUnifiedRobotData } from "@/lib/unified-storage";
|
| 24 |
+
import VirtualKey from "@/components/VirtualKey";
|
| 25 |
|
| 26 |
interface TeleoperationViewProps {
|
| 27 |
+
robot: RobotConnection;
|
| 28 |
}
|
| 29 |
|
| 30 |
+
// Keyboard controls for SO-100 (from conventions)
|
| 31 |
+
const SO100_KEYBOARD_CONTROLS = {
|
| 32 |
+
shoulder_pan: { positive: "ArrowRight", negative: "ArrowLeft" },
|
| 33 |
+
shoulder_lift: { positive: "ArrowUp", negative: "ArrowDown" },
|
| 34 |
+
elbow_flex: { positive: "w", negative: "s" },
|
| 35 |
+
wrist_flex: { positive: "a", negative: "d" },
|
| 36 |
+
wrist_roll: { positive: "q", negative: "e" },
|
| 37 |
+
gripper: { positive: "o", negative: "c" },
|
| 38 |
+
stop: "Escape",
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
// Default motor configurations for immediate display
|
| 42 |
+
const DEFAULT_MOTOR_CONFIGS = [
|
| 43 |
+
{
|
| 44 |
+
name: "shoulder_pan",
|
| 45 |
+
currentPosition: 2048,
|
| 46 |
+
minPosition: 0,
|
| 47 |
+
maxPosition: 4095,
|
| 48 |
+
},
|
| 49 |
+
{
|
| 50 |
+
name: "shoulder_lift",
|
| 51 |
+
currentPosition: 2048,
|
| 52 |
+
minPosition: 0,
|
| 53 |
+
maxPosition: 4095,
|
| 54 |
+
},
|
| 55 |
+
{
|
| 56 |
+
name: "elbow_flex",
|
| 57 |
+
currentPosition: 2048,
|
| 58 |
+
minPosition: 0,
|
| 59 |
+
maxPosition: 4095,
|
| 60 |
+
},
|
| 61 |
+
{
|
| 62 |
+
name: "wrist_flex",
|
| 63 |
+
currentPosition: 2048,
|
| 64 |
+
minPosition: 0,
|
| 65 |
+
maxPosition: 4095,
|
| 66 |
+
},
|
| 67 |
+
{
|
| 68 |
+
name: "wrist_roll",
|
| 69 |
+
currentPosition: 2048,
|
| 70 |
+
minPosition: 0,
|
| 71 |
+
maxPosition: 4095,
|
| 72 |
+
},
|
| 73 |
+
{ name: "gripper", currentPosition: 2048, minPosition: 0, maxPosition: 4095 },
|
| 74 |
+
];
|
| 75 |
+
|
| 76 |
export function TeleoperationView({ robot }: TeleoperationViewProps) {
|
| 77 |
+
const [teleopState, setTeleopState] = useState<TeleoperationState>({
|
| 78 |
+
isActive: false,
|
| 79 |
+
motorConfigs: [],
|
| 80 |
+
lastUpdate: 0,
|
| 81 |
+
keyStates: {},
|
| 82 |
+
});
|
| 83 |
+
|
| 84 |
+
const [isInitialized, setIsInitialized] = useState(false);
|
| 85 |
+
// Local slider positions for immediate UI feedback with timestamps
|
| 86 |
+
const [localMotorPositions, setLocalMotorPositions] = useState<{
|
| 87 |
+
[motorName: string]: { position: number; timestamp: number };
|
| 88 |
+
}>({});
|
| 89 |
+
const keyboardProcessRef = useRef<TeleoperationProcess | null>(null);
|
| 90 |
+
const directProcessRef = useRef<TeleoperationProcess | null>(null);
|
| 91 |
+
const { toast } = useToast();
|
| 92 |
|
| 93 |
+
// Load calibration data from unified storage
|
| 94 |
const calibrationData = useMemo(() => {
|
| 95 |
+
if (!robot.serialNumber) return undefined;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
|
| 97 |
+
const data = getUnifiedRobotData(robot.serialNumber);
|
| 98 |
+
if (data?.calibration) {
|
| 99 |
+
return data.calibration;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
}
|
|
|
|
| 101 |
|
| 102 |
+
// Return undefined if no calibration data - let library handle defaults
|
| 103 |
+
return undefined;
|
| 104 |
+
}, [robot.serialNumber]);
|
| 105 |
|
| 106 |
+
// Lazy initialization function - only connects when user wants to start
|
| 107 |
+
const initializeTeleoperation = async () => {
|
| 108 |
+
if (!robot || !robot.robotType) {
|
| 109 |
+
return false;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
try {
|
| 113 |
+
// Create keyboard teleoperation process
|
| 114 |
+
const keyboardConfig: TeleoperateConfig = {
|
| 115 |
+
robot: robot,
|
| 116 |
+
teleop: {
|
| 117 |
+
type: "keyboard",
|
| 118 |
+
},
|
| 119 |
+
calibrationData,
|
| 120 |
+
onStateUpdate: (state: TeleoperationState) => {
|
| 121 |
+
setTeleopState(state);
|
| 122 |
+
},
|
| 123 |
+
};
|
| 124 |
+
const keyboardProcess = await teleoperate(keyboardConfig);
|
| 125 |
|
| 126 |
+
// Create direct teleoperation process
|
| 127 |
+
const directConfig: TeleoperateConfig = {
|
| 128 |
+
robot: robot,
|
| 129 |
+
teleop: {
|
| 130 |
+
type: "direct",
|
| 131 |
+
},
|
| 132 |
+
calibrationData,
|
| 133 |
+
};
|
| 134 |
+
const directProcess = await teleoperate(directConfig);
|
| 135 |
+
|
| 136 |
+
keyboardProcessRef.current = keyboardProcess;
|
| 137 |
+
directProcessRef.current = directProcess;
|
| 138 |
+
setTeleopState(keyboardProcess.getState());
|
| 139 |
+
|
| 140 |
+
// Initialize local motor positions from hardware state
|
| 141 |
+
const initialState = keyboardProcess.getState();
|
| 142 |
+
const initialPositions: {
|
| 143 |
+
[motorName: string]: { position: number; timestamp: number };
|
| 144 |
+
} = {};
|
| 145 |
+
initialState.motorConfigs.forEach((motor) => {
|
| 146 |
+
initialPositions[motor.name] = {
|
| 147 |
+
position: motor.currentPosition,
|
| 148 |
+
timestamp: Date.now(),
|
| 149 |
+
};
|
| 150 |
+
});
|
| 151 |
+
setLocalMotorPositions(initialPositions);
|
| 152 |
+
|
| 153 |
+
setIsInitialized(true);
|
| 154 |
+
|
| 155 |
+
return true;
|
| 156 |
+
} catch (error) {
|
| 157 |
+
const errorMessage =
|
| 158 |
+
error instanceof Error
|
| 159 |
+
? error.message
|
| 160 |
+
: "Failed to initialize teleoperation";
|
| 161 |
+
toast({
|
| 162 |
+
title: "Teleoperation Error",
|
| 163 |
+
description: errorMessage,
|
| 164 |
+
variant: "destructive",
|
| 165 |
+
});
|
| 166 |
+
return false;
|
| 167 |
+
}
|
| 168 |
+
};
|
| 169 |
+
|
| 170 |
+
// Cleanup on unmount
|
| 171 |
+
useEffect(() => {
|
| 172 |
return () => {
|
| 173 |
+
const cleanup = async () => {
|
| 174 |
+
try {
|
| 175 |
+
if (keyboardProcessRef.current) {
|
| 176 |
+
await keyboardProcessRef.current.disconnect();
|
| 177 |
+
keyboardProcessRef.current = null;
|
| 178 |
+
}
|
| 179 |
+
if (directProcessRef.current) {
|
| 180 |
+
await directProcessRef.current.disconnect();
|
| 181 |
+
directProcessRef.current = null;
|
| 182 |
+
}
|
| 183 |
+
} catch (error) {
|
| 184 |
+
console.warn("Error during teleoperation cleanup:", error);
|
| 185 |
+
}
|
| 186 |
+
};
|
| 187 |
+
cleanup();
|
| 188 |
+
};
|
| 189 |
+
}, []);
|
| 190 |
+
|
| 191 |
+
// Keyboard event handlers
|
| 192 |
+
const handleKeyDown = useCallback(
|
| 193 |
+
(event: KeyboardEvent) => {
|
| 194 |
+
if (!teleopState.isActive || !keyboardProcessRef.current) return;
|
| 195 |
+
|
| 196 |
+
const key = event.key;
|
| 197 |
+
event.preventDefault();
|
| 198 |
+
|
| 199 |
+
const keyboardTeleoperator = keyboardProcessRef.current.teleoperator;
|
| 200 |
+
if (keyboardTeleoperator && "updateKeyState" in keyboardTeleoperator) {
|
| 201 |
+
(
|
| 202 |
+
keyboardTeleoperator as {
|
| 203 |
+
updateKeyState: (key: string, pressed: boolean) => void;
|
| 204 |
+
}
|
| 205 |
+
).updateKeyState(key, true);
|
| 206 |
+
}
|
| 207 |
+
},
|
| 208 |
+
[teleopState.isActive]
|
| 209 |
+
);
|
| 210 |
+
|
| 211 |
+
const handleKeyUp = useCallback(
|
| 212 |
+
(event: KeyboardEvent) => {
|
| 213 |
+
if (!teleopState.isActive || !keyboardProcessRef.current) return;
|
| 214 |
+
|
| 215 |
+
const key = event.key;
|
| 216 |
+
event.preventDefault();
|
| 217 |
+
|
| 218 |
+
const keyboardTeleoperator = keyboardProcessRef.current.teleoperator;
|
| 219 |
+
if (keyboardTeleoperator && "updateKeyState" in keyboardTeleoperator) {
|
| 220 |
+
(
|
| 221 |
+
keyboardTeleoperator as {
|
| 222 |
+
updateKeyState: (key: string, pressed: boolean) => void;
|
| 223 |
+
}
|
| 224 |
+
).updateKeyState(key, false);
|
| 225 |
+
}
|
| 226 |
+
},
|
| 227 |
+
[teleopState.isActive]
|
| 228 |
+
);
|
| 229 |
+
|
| 230 |
+
// Register keyboard events
|
| 231 |
+
useEffect(() => {
|
| 232 |
+
if (teleopState.isActive) {
|
| 233 |
+
window.addEventListener("keydown", handleKeyDown);
|
| 234 |
+
window.addEventListener("keyup", handleKeyUp);
|
| 235 |
+
|
| 236 |
+
return () => {
|
| 237 |
+
window.removeEventListener("keydown", handleKeyDown);
|
| 238 |
+
window.removeEventListener("keyup", handleKeyUp);
|
| 239 |
+
};
|
| 240 |
+
}
|
| 241 |
+
}, [teleopState.isActive, handleKeyDown, handleKeyUp]);
|
| 242 |
+
|
| 243 |
+
const handleStart = async () => {
|
| 244 |
+
// Initialize on first use if not already initialized
|
| 245 |
+
if (!isInitialized) {
|
| 246 |
+
const success = await initializeTeleoperation();
|
| 247 |
+
if (!success) return;
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
if (!keyboardProcessRef.current || !directProcessRef.current) {
|
| 251 |
+
toast({
|
| 252 |
+
title: "Teleoperation Error",
|
| 253 |
+
description: "Teleoperation not initialized",
|
| 254 |
+
variant: "destructive",
|
| 255 |
+
});
|
| 256 |
+
return;
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
try {
|
| 260 |
+
keyboardProcessRef.current.start();
|
| 261 |
+
directProcessRef.current.start();
|
| 262 |
+
} catch (error) {
|
| 263 |
+
const errorMessage =
|
| 264 |
+
error instanceof Error
|
| 265 |
+
? error.message
|
| 266 |
+
: "Failed to start teleoperation";
|
| 267 |
+
toast({
|
| 268 |
+
title: "Start Error",
|
| 269 |
+
description: errorMessage,
|
| 270 |
+
variant: "destructive",
|
| 271 |
+
});
|
| 272 |
+
}
|
| 273 |
+
};
|
| 274 |
+
|
| 275 |
+
const handleStop = async () => {
|
| 276 |
+
try {
|
| 277 |
+
if (keyboardProcessRef.current) {
|
| 278 |
+
keyboardProcessRef.current.stop();
|
| 279 |
+
}
|
| 280 |
+
if (directProcessRef.current) {
|
| 281 |
+
directProcessRef.current.stop();
|
| 282 |
+
}
|
| 283 |
+
} catch (error) {
|
| 284 |
+
console.warn("Error during teleoperation stop:", error);
|
| 285 |
}
|
| 286 |
+
};
|
| 287 |
+
|
| 288 |
+
// Virtual keyboard functions
|
| 289 |
+
const simulateKeyPress = (key: string) => {
|
| 290 |
+
if (!keyboardProcessRef.current || !teleopState.isActive) return;
|
| 291 |
+
|
| 292 |
+
const keyboardTeleoperator = keyboardProcessRef.current.teleoperator;
|
| 293 |
+
if (keyboardTeleoperator && "updateKeyState" in keyboardTeleoperator) {
|
| 294 |
+
(
|
| 295 |
+
keyboardTeleoperator as {
|
| 296 |
+
updateKeyState: (key: string, pressed: boolean) => void;
|
| 297 |
+
}
|
| 298 |
+
).updateKeyState(key, true);
|
| 299 |
+
}
|
| 300 |
+
};
|
| 301 |
+
|
| 302 |
+
const simulateKeyRelease = (key: string) => {
|
| 303 |
+
if (!keyboardProcessRef.current || !teleopState.isActive) return;
|
| 304 |
+
|
| 305 |
+
const keyboardTeleoperator = keyboardProcessRef.current.teleoperator;
|
| 306 |
+
if (keyboardTeleoperator && "updateKeyState" in keyboardTeleoperator) {
|
| 307 |
+
(
|
| 308 |
+
keyboardTeleoperator as {
|
| 309 |
+
updateKeyState: (key: string, pressed: boolean) => void;
|
| 310 |
+
}
|
| 311 |
+
).updateKeyState(key, false);
|
| 312 |
+
}
|
| 313 |
+
};
|
| 314 |
+
|
| 315 |
+
// Motor control through direct teleoperator
|
| 316 |
+
const moveMotor = async (motorName: string, position: number) => {
|
| 317 |
+
if (!directProcessRef.current) return;
|
| 318 |
+
|
| 319 |
+
try {
|
| 320 |
+
// Immediately update local UI state for responsive slider feedback
|
| 321 |
+
setLocalMotorPositions((prev) => ({
|
| 322 |
+
...prev,
|
| 323 |
+
[motorName]: { position, timestamp: Date.now() },
|
| 324 |
+
}));
|
| 325 |
+
|
| 326 |
+
const directTeleoperator = directProcessRef.current.teleoperator;
|
| 327 |
+
if (directTeleoperator && "moveMotor" in directTeleoperator) {
|
| 328 |
+
await (
|
| 329 |
+
directTeleoperator as {
|
| 330 |
+
moveMotor: (motorName: string, position: number) => Promise<void>;
|
| 331 |
+
}
|
| 332 |
+
).moveMotor(motorName, position);
|
| 333 |
+
}
|
| 334 |
+
} catch (error) {
|
| 335 |
+
console.warn(
|
| 336 |
+
`Failed to move motor ${motorName} to position ${position}:`,
|
| 337 |
+
error
|
| 338 |
+
);
|
| 339 |
+
toast({
|
| 340 |
+
title: "Motor Control Error",
|
| 341 |
+
description: `Failed to move ${motorName}`,
|
| 342 |
+
variant: "destructive",
|
| 343 |
+
});
|
| 344 |
+
}
|
| 345 |
+
};
|
| 346 |
+
|
| 347 |
+
// Merge hardware state with local UI state for responsive sliders
|
| 348 |
+
const motorConfigs = useMemo(() => {
|
| 349 |
+
const realMotorConfigs = teleopState?.motorConfigs || [];
|
| 350 |
+
const now = Date.now();
|
| 351 |
+
|
| 352 |
+
// If we have real motor configs, use them with local position overrides when recent
|
| 353 |
+
if (realMotorConfigs.length > 0) {
|
| 354 |
+
return realMotorConfigs.map((motor) => {
|
| 355 |
+
const localData = localMotorPositions[motor.name];
|
| 356 |
+
// Use local position only if it's very recent (within 100ms), otherwise use hardware position
|
| 357 |
+
const useLocalPosition = localData && now - localData.timestamp < 100;
|
| 358 |
+
return {
|
| 359 |
+
...motor,
|
| 360 |
+
currentPosition: useLocalPosition
|
| 361 |
+
? localData.position
|
| 362 |
+
: motor.currentPosition,
|
| 363 |
+
};
|
| 364 |
+
});
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
// Otherwise, show default configs with calibration data if available
|
| 368 |
+
return DEFAULT_MOTOR_CONFIGS.map((motor) => {
|
| 369 |
+
const calibratedMotor = calibrationData?.[motor.name];
|
| 370 |
+
const localData = localMotorPositions[motor.name];
|
| 371 |
+
const useLocalPosition = localData && now - localData.timestamp < 100;
|
| 372 |
+
|
| 373 |
+
return {
|
| 374 |
+
...motor,
|
| 375 |
+
minPosition: calibratedMotor?.range_min ?? motor.minPosition,
|
| 376 |
+
maxPosition: calibratedMotor?.range_max ?? motor.maxPosition,
|
| 377 |
+
// Show 0 when inactive to look deactivated, local/real position when active
|
| 378 |
+
currentPosition: teleopState?.isActive
|
| 379 |
+
? useLocalPosition
|
| 380 |
+
? localData.position
|
| 381 |
+
: motor.currentPosition
|
| 382 |
+
: 0,
|
| 383 |
+
};
|
| 384 |
+
});
|
| 385 |
+
}, [
|
| 386 |
+
teleopState?.motorConfigs,
|
| 387 |
+
teleopState?.isActive,
|
| 388 |
+
localMotorPositions,
|
| 389 |
+
calibrationData,
|
| 390 |
+
]);
|
| 391 |
+
|
| 392 |
+
const keyStates = teleopState?.keyStates || {};
|
| 393 |
+
const controls = SO100_KEYBOARD_CONTROLS;
|
| 394 |
|
| 395 |
return (
|
| 396 |
<Card className="border-0 rounded-none">
|
|
|
|
| 399 |
<div className="flex items-center gap-4">
|
| 400 |
<div className="w-1 h-8 bg-primary"></div>
|
| 401 |
<div>
|
| 402 |
+
<h3 className="text-xl font-bold text-foreground font-mono tracking-wider uppercase">
|
| 403 |
+
robot control
|
| 404 |
+
</h3>
|
| 405 |
<p className="text-sm text-muted-foreground font-mono">
|
| 406 |
+
manual{" "}
|
| 407 |
+
<span className="text-muted-foreground">teleoperate</span>{" "}
|
| 408 |
+
interface
|
| 409 |
</p>
|
| 410 |
</div>
|
| 411 |
</div>
|
| 412 |
<div className="flex items-center gap-6">
|
| 413 |
<div className="border-l border-white/10 pl-6 flex items-center gap-4">
|
| 414 |
{teleopState?.isActive ? (
|
| 415 |
+
<Button onClick={handleStop} variant="destructive" size="lg">
|
| 416 |
<PowerOff className="w-5 h-5 mr-2" /> Stop Control
|
| 417 |
</Button>
|
| 418 |
) : (
|
| 419 |
+
<Button
|
| 420 |
+
onClick={handleStart}
|
| 421 |
+
size="lg"
|
| 422 |
+
disabled={!robot.isConnected}
|
| 423 |
+
>
|
| 424 |
<Power className="w-5 h-5 mr-2" /> Control Robot
|
| 425 |
</Button>
|
| 426 |
)}
|
| 427 |
<div className="flex items-center gap-2">
|
| 428 |
+
<span className="text-sm font-mono text-muted-foreground uppercase">
|
| 429 |
+
status:
|
| 430 |
+
</span>
|
| 431 |
<Badge
|
| 432 |
variant="outline"
|
| 433 |
className={cn(
|
| 434 |
"border-primary/50 bg-primary/20 text-primary font-mono text-xs",
|
| 435 |
+
teleopState?.isActive && "animate-pulse-slow"
|
| 436 |
)}
|
| 437 |
>
|
| 438 |
{teleopState?.isActive ? "ACTIVE" : "STOPPED"}
|
|
|
|
| 444 |
</div>
|
| 445 |
<div className="pt-6 p-6 grid md:grid-cols-2 gap-8">
|
| 446 |
<div>
|
| 447 |
+
<h3 className="font-sans font-semibold mb-4 text-xl">
|
| 448 |
+
Motor Control
|
| 449 |
+
</h3>
|
| 450 |
<div className="space-y-6">
|
| 451 |
{motorConfigs.map((motor) => (
|
| 452 |
<div key={motor.name}>
|
| 453 |
+
<label className="text-sm font-mono text-muted-foreground">
|
| 454 |
+
{motor.name}
|
| 455 |
+
</label>
|
| 456 |
<div className="flex items-center gap-4">
|
| 457 |
<Slider
|
| 458 |
value={[motor.currentPosition]}
|
| 459 |
min={motor.minPosition}
|
| 460 |
max={motor.maxPosition}
|
| 461 |
step={1}
|
| 462 |
+
onValueChange={(val) => moveMotor(motor.name, val[0])}
|
| 463 |
disabled={!teleopState?.isActive}
|
| 464 |
+
className={!teleopState?.isActive ? "opacity-50" : ""}
|
| 465 |
/>
|
| 466 |
+
<span
|
| 467 |
+
className={cn(
|
| 468 |
+
"text-lg font-mono w-16 text-right",
|
| 469 |
+
teleopState?.isActive
|
| 470 |
+
? "text-accent"
|
| 471 |
+
: "text-muted-foreground"
|
| 472 |
+
)}
|
| 473 |
+
>
|
| 474 |
{Math.round(motor.currentPosition)}
|
| 475 |
</span>
|
| 476 |
</div>
|
|
|
|
| 479 |
</div>
|
| 480 |
</div>
|
| 481 |
<div>
|
| 482 |
+
<h3 className="font-sans font-semibold mb-4 text-xl">
|
| 483 |
+
Keyboard Layout & Status
|
| 484 |
+
</h3>
|
| 485 |
<div className="p-4 bg-black/30 rounded-lg space-y-4">
|
| 486 |
<div className="flex justify-around items-end">
|
| 487 |
<div className="flex flex-col items-center gap-2">
|
| 488 |
<VirtualKey
|
| 489 |
label="↑"
|
| 490 |
subLabel="Lift+"
|
| 491 |
+
isPressed={
|
| 492 |
+
!!keyStates[controls.shoulder_lift.positive]?.pressed
|
| 493 |
+
}
|
| 494 |
+
onMouseDown={() =>
|
| 495 |
+
simulateKeyPress(controls.shoulder_lift.positive)
|
| 496 |
+
}
|
| 497 |
+
onMouseUp={() =>
|
| 498 |
+
simulateKeyRelease(controls.shoulder_lift.positive)
|
| 499 |
+
}
|
| 500 |
+
disabled={!teleopState?.isActive}
|
| 501 |
/>
|
| 502 |
<div className="flex gap-2">
|
| 503 |
<VirtualKey
|
| 504 |
label="←"
|
| 505 |
subLabel="Pan-"
|
| 506 |
+
isPressed={
|
| 507 |
+
!!keyStates[controls.shoulder_pan.negative]?.pressed
|
| 508 |
+
}
|
| 509 |
+
onMouseDown={() =>
|
| 510 |
+
simulateKeyPress(controls.shoulder_pan.negative)
|
| 511 |
+
}
|
| 512 |
+
onMouseUp={() =>
|
| 513 |
+
simulateKeyRelease(controls.shoulder_pan.negative)
|
| 514 |
+
}
|
| 515 |
+
disabled={!teleopState?.isActive}
|
| 516 |
/>
|
| 517 |
<VirtualKey
|
| 518 |
label="↓"
|
| 519 |
subLabel="Lift-"
|
| 520 |
+
isPressed={
|
| 521 |
+
!!keyStates[controls.shoulder_lift.negative]?.pressed
|
| 522 |
+
}
|
| 523 |
+
onMouseDown={() =>
|
| 524 |
+
simulateKeyPress(controls.shoulder_lift.negative)
|
| 525 |
+
}
|
| 526 |
+
onMouseUp={() =>
|
| 527 |
+
simulateKeyRelease(controls.shoulder_lift.negative)
|
| 528 |
+
}
|
| 529 |
+
disabled={!teleopState?.isActive}
|
| 530 |
/>
|
| 531 |
<VirtualKey
|
| 532 |
label="→"
|
| 533 |
subLabel="Pan+"
|
| 534 |
+
isPressed={
|
| 535 |
+
!!keyStates[controls.shoulder_pan.positive]?.pressed
|
| 536 |
+
}
|
| 537 |
+
onMouseDown={() =>
|
| 538 |
+
simulateKeyPress(controls.shoulder_pan.positive)
|
| 539 |
+
}
|
| 540 |
+
onMouseUp={() =>
|
| 541 |
+
simulateKeyRelease(controls.shoulder_pan.positive)
|
| 542 |
+
}
|
| 543 |
+
disabled={!teleopState?.isActive}
|
| 544 |
/>
|
| 545 |
</div>
|
| 546 |
<span className="font-bold text-sm font-sans">Shoulder</span>
|
|
|
|
| 550 |
label="W"
|
| 551 |
subLabel="Elbow+"
|
| 552 |
isPressed={!!keyStates[controls.elbow_flex.positive]?.pressed}
|
| 553 |
+
onMouseDown={() =>
|
| 554 |
+
simulateKeyPress(controls.elbow_flex.positive)
|
| 555 |
+
}
|
| 556 |
+
onMouseUp={() =>
|
| 557 |
+
simulateKeyRelease(controls.elbow_flex.positive)
|
| 558 |
+
}
|
| 559 |
+
disabled={!teleopState?.isActive}
|
| 560 |
/>
|
| 561 |
<div className="flex gap-2">
|
| 562 |
<VirtualKey
|
| 563 |
label="A"
|
| 564 |
subLabel="Wrist+"
|
| 565 |
+
isPressed={
|
| 566 |
+
!!keyStates[controls.wrist_flex.positive]?.pressed
|
| 567 |
+
}
|
| 568 |
+
onMouseDown={() =>
|
| 569 |
+
simulateKeyPress(controls.wrist_flex.positive)
|
| 570 |
+
}
|
| 571 |
+
onMouseUp={() =>
|
| 572 |
+
simulateKeyRelease(controls.wrist_flex.positive)
|
| 573 |
+
}
|
| 574 |
+
disabled={!teleopState?.isActive}
|
| 575 |
/>
|
| 576 |
<VirtualKey
|
| 577 |
label="S"
|
| 578 |
subLabel="Elbow-"
|
| 579 |
+
isPressed={
|
| 580 |
+
!!keyStates[controls.elbow_flex.negative]?.pressed
|
| 581 |
+
}
|
| 582 |
+
onMouseDown={() =>
|
| 583 |
+
simulateKeyPress(controls.elbow_flex.negative)
|
| 584 |
+
}
|
| 585 |
+
onMouseUp={() =>
|
| 586 |
+
simulateKeyRelease(controls.elbow_flex.negative)
|
| 587 |
+
}
|
| 588 |
+
disabled={!teleopState?.isActive}
|
| 589 |
/>
|
| 590 |
<VirtualKey
|
| 591 |
label="D"
|
| 592 |
subLabel="Wrist-"
|
| 593 |
+
isPressed={
|
| 594 |
+
!!keyStates[controls.wrist_flex.negative]?.pressed
|
| 595 |
+
}
|
| 596 |
+
onMouseDown={() =>
|
| 597 |
+
simulateKeyPress(controls.wrist_flex.negative)
|
| 598 |
+
}
|
| 599 |
+
onMouseUp={() =>
|
| 600 |
+
simulateKeyRelease(controls.wrist_flex.negative)
|
| 601 |
+
}
|
| 602 |
+
disabled={!teleopState?.isActive}
|
| 603 |
/>
|
| 604 |
</div>
|
| 605 |
<span className="font-bold text-sm font-sans">Elbow/Wrist</span>
|
|
|
|
| 609 |
<VirtualKey
|
| 610 |
label="Q"
|
| 611 |
subLabel="Roll+"
|
| 612 |
+
isPressed={
|
| 613 |
+
!!keyStates[controls.wrist_roll.positive]?.pressed
|
| 614 |
+
}
|
| 615 |
+
onMouseDown={() =>
|
| 616 |
+
simulateKeyPress(controls.wrist_roll.positive)
|
| 617 |
+
}
|
| 618 |
+
onMouseUp={() =>
|
| 619 |
+
simulateKeyRelease(controls.wrist_roll.positive)
|
| 620 |
+
}
|
| 621 |
+
disabled={!teleopState?.isActive}
|
| 622 |
/>
|
| 623 |
<VirtualKey
|
| 624 |
label="E"
|
| 625 |
subLabel="Roll-"
|
| 626 |
+
isPressed={
|
| 627 |
+
!!keyStates[controls.wrist_roll.negative]?.pressed
|
| 628 |
+
}
|
| 629 |
+
onMouseDown={() =>
|
| 630 |
+
simulateKeyPress(controls.wrist_roll.negative)
|
| 631 |
+
}
|
| 632 |
+
onMouseUp={() =>
|
| 633 |
+
simulateKeyRelease(controls.wrist_roll.negative)
|
| 634 |
+
}
|
| 635 |
+
disabled={!teleopState?.isActive}
|
| 636 |
/>
|
| 637 |
</div>
|
| 638 |
<div className="flex gap-2">
|
| 639 |
+
<VirtualKey
|
| 640 |
+
label="O"
|
| 641 |
+
subLabel="Grip+"
|
| 642 |
+
isPressed={!!keyStates[controls.gripper.positive]?.pressed}
|
| 643 |
+
onMouseDown={() =>
|
| 644 |
+
simulateKeyPress(controls.gripper.positive)
|
| 645 |
+
}
|
| 646 |
+
onMouseUp={() =>
|
| 647 |
+
simulateKeyRelease(controls.gripper.positive)
|
| 648 |
+
}
|
| 649 |
+
disabled={!teleopState?.isActive}
|
| 650 |
+
/>
|
| 651 |
+
<VirtualKey
|
| 652 |
+
label="C"
|
| 653 |
+
subLabel="Grip-"
|
| 654 |
+
isPressed={!!keyStates[controls.gripper.negative]?.pressed}
|
| 655 |
+
onMouseDown={() =>
|
| 656 |
+
simulateKeyPress(controls.gripper.negative)
|
| 657 |
+
}
|
| 658 |
+
onMouseUp={() =>
|
| 659 |
+
simulateKeyRelease(controls.gripper.negative)
|
| 660 |
+
}
|
| 661 |
+
disabled={!teleopState?.isActive}
|
| 662 |
+
/>
|
| 663 |
</div>
|
| 664 |
<span className="font-bold text-sm font-sans">Roll/Grip</span>
|
| 665 |
</div>
|
|
|
|
| 668 |
<div className="flex justify-between items-center font-mono text-sm">
|
| 669 |
<div className="flex items-center gap-2 text-muted-foreground">
|
| 670 |
<Keyboard className="w-4 h-4" />
|
| 671 |
+
<span>
|
| 672 |
+
Active Keys:{" "}
|
| 673 |
+
{Object.values(keyStates).filter((k) => k.pressed).length}
|
| 674 |
+
</span>
|
| 675 |
</div>
|
| 676 |
<TooltipProvider>
|
| 677 |
<Tooltip>
|
|
|
|
| 679 |
<div
|
| 680 |
className={cn(
|
| 681 |
"w-10 h-6 border rounded-md flex items-center justify-center font-mono text-xs transition-all",
|
| 682 |
+
"select-none user-select-none",
|
| 683 |
+
!teleopState?.isActive &&
|
| 684 |
+
"opacity-50 cursor-not-allowed",
|
| 685 |
+
teleopState?.isActive &&
|
| 686 |
+
"cursor-pointer hover:bg-white/5",
|
| 687 |
+
keyStates[controls.stop]?.pressed
|
| 688 |
? "bg-destructive text-destructive-foreground border-destructive"
|
| 689 |
+
: "bg-background"
|
| 690 |
)}
|
| 691 |
+
onMouseDown={(e) => {
|
| 692 |
+
e.preventDefault();
|
| 693 |
+
if (teleopState?.isActive) {
|
| 694 |
+
simulateKeyPress(controls.stop);
|
| 695 |
+
}
|
| 696 |
+
}}
|
| 697 |
+
onMouseUp={(e) => {
|
| 698 |
+
e.preventDefault();
|
| 699 |
+
if (teleopState?.isActive) {
|
| 700 |
+
simulateKeyRelease(controls.stop);
|
| 701 |
+
}
|
| 702 |
+
}}
|
| 703 |
+
onMouseLeave={(e) => {
|
| 704 |
+
e.preventDefault();
|
| 705 |
+
if (teleopState?.isActive) {
|
| 706 |
+
simulateKeyRelease(controls.stop);
|
| 707 |
+
}
|
| 708 |
+
}}
|
| 709 |
>
|
| 710 |
ESC
|
| 711 |
</div>
|
|
|
|
| 719 |
</div>
|
| 720 |
</div>
|
| 721 |
</Card>
|
| 722 |
+
);
|
| 723 |
}
|
examples/cyberpunk-standalone/src/global.css
CHANGED
|
@@ -123,35 +123,11 @@
|
|
| 123 |
opacity: 1;
|
| 124 |
}
|
| 125 |
body.dark {
|
| 126 |
-
background-image:
|
| 127 |
-
circle at var(--mouse-x) var(--mouse-y),
|
| 128 |
-
rgba(255, 193, 7, 0.08),
|
| 129 |
-
transparent 20vw
|
| 130 |
-
),
|
| 131 |
-
url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' width='32' height='32' fill='none' stroke='rgb(255 255 255 / 0.05)'%3e%3cpath d='M0 .5H31.5V32'/%3e%3c/svg%3e");
|
| 132 |
}
|
| 133 |
}
|
| 134 |
|
| 135 |
@layer utilities {
|
| 136 |
-
.scanline-overlay::after {
|
| 137 |
-
content: "";
|
| 138 |
-
position: absolute;
|
| 139 |
-
top: 0;
|
| 140 |
-
left: 0;
|
| 141 |
-
right: 0;
|
| 142 |
-
bottom: 0;
|
| 143 |
-
pointer-events: none;
|
| 144 |
-
background: linear-gradient(
|
| 145 |
-
to bottom,
|
| 146 |
-
rgba(18, 18, 18, 0) 50%,
|
| 147 |
-
rgba(0, 0, 0, 0.2) 70%,
|
| 148 |
-
rgba(18, 18, 18, 0.75)
|
| 149 |
-
);
|
| 150 |
-
animation: scanline 8s linear infinite;
|
| 151 |
-
opacity: 0.1;
|
| 152 |
-
height: 200%;
|
| 153 |
-
}
|
| 154 |
-
|
| 155 |
.text-glitch {
|
| 156 |
animation: text-glitch 0.3s linear infinite alternate-reverse;
|
| 157 |
}
|
|
|
|
| 123 |
opacity: 1;
|
| 124 |
}
|
| 125 |
body.dark {
|
| 126 |
+
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' width='32' height='32' fill='none' stroke='rgb(255 255 255 / 0.05)'%3e%3cpath d='M0 .5H31.5V32'/%3e%3c/svg%3e");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
}
|
| 128 |
}
|
| 129 |
|
| 130 |
@layer utilities {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
.text-glitch {
|
| 132 |
animation: text-glitch 0.3s linear infinite alternate-reverse;
|
| 133 |
}
|
examples/cyberpunk-standalone/src/lib/mock-api.ts
DELETED
|
@@ -1,137 +0,0 @@
|
|
| 1 |
-
import type { RobotConnection, LiveCalibrationData, WebCalibrationResults, TeleoperationState } from "@/types/robot"
|
| 2 |
-
|
| 3 |
-
const MOCK_MOTOR_NAMES = ["shoulder_pan", "shoulder_lift", "elbow_flex", "wrist_flex", "wrist_roll", "gripper"]
|
| 4 |
-
|
| 5 |
-
export const lerobot = {
|
| 6 |
-
isWebSerialSupported: () => true,
|
| 7 |
-
findPort: async ({
|
| 8 |
-
robotConfigs,
|
| 9 |
-
onMessage,
|
| 10 |
-
}: { robotConfigs?: any[]; onMessage?: (msg: string) => void }): Promise<{ result: Promise<RobotConnection[]> }> => {
|
| 11 |
-
onMessage?.("Searching for robots...")
|
| 12 |
-
const resultPromise = new Promise<RobotConnection[]>((resolve) => {
|
| 13 |
-
setTimeout(() => {
|
| 14 |
-
if (robotConfigs && robotConfigs.length > 0) {
|
| 15 |
-
onMessage?.(`Found ${robotConfigs.length} saved robots.`)
|
| 16 |
-
const reconnectedRobots = robotConfigs.map((config) => ({
|
| 17 |
-
...config,
|
| 18 |
-
isConnected: Math.random() > 0.3,
|
| 19 |
-
port: {},
|
| 20 |
-
}))
|
| 21 |
-
resolve(reconnectedRobots)
|
| 22 |
-
} else {
|
| 23 |
-
onMessage?.("Simulating successful device connection.")
|
| 24 |
-
const newRobot: RobotConnection = {
|
| 25 |
-
port: {},
|
| 26 |
-
name: "Cyber-Arm 7",
|
| 27 |
-
isConnected: true,
|
| 28 |
-
robotType: "so100_follower",
|
| 29 |
-
robotId: `robot-${Date.now()}`,
|
| 30 |
-
serialNumber: `SN-${Math.floor(Math.random() * 1000000)}`,
|
| 31 |
-
}
|
| 32 |
-
resolve([newRobot])
|
| 33 |
-
}
|
| 34 |
-
}, 1500)
|
| 35 |
-
})
|
| 36 |
-
return { result: resultPromise }
|
| 37 |
-
},
|
| 38 |
-
calibrate: async (
|
| 39 |
-
robot: RobotConnection,
|
| 40 |
-
{
|
| 41 |
-
onLiveUpdate,
|
| 42 |
-
onProgress,
|
| 43 |
-
}: { onLiveUpdate: (data: LiveCalibrationData) => void; onProgress: (msg: string) => void },
|
| 44 |
-
): Promise<{ result: Promise<WebCalibrationResults>; stop: () => void }> => {
|
| 45 |
-
onProgress("MOCK: Starting calibration... Move all joints to their limits.")
|
| 46 |
-
let intervalId: NodeJS.Timeout
|
| 47 |
-
const liveData: LiveCalibrationData = MOCK_MOTOR_NAMES.reduce((acc, name) => {
|
| 48 |
-
acc[name] = { current: 2048, min: 4095, max: 0, range: 0 }
|
| 49 |
-
return acc
|
| 50 |
-
}, {} as LiveCalibrationData)
|
| 51 |
-
intervalId = setInterval(() => {
|
| 52 |
-
MOCK_MOTOR_NAMES.forEach((name) => {
|
| 53 |
-
const motor = liveData[name]
|
| 54 |
-
motor.current = 2048 + Math.floor((Math.random() - 0.5) * 4000)
|
| 55 |
-
motor.min = Math.min(motor.min, motor.current)
|
| 56 |
-
motor.max = Math.max(motor.max, motor.current)
|
| 57 |
-
motor.range = motor.max - motor.min
|
| 58 |
-
})
|
| 59 |
-
onLiveUpdate({ ...liveData })
|
| 60 |
-
}, 100)
|
| 61 |
-
const resultPromise = new Promise<WebCalibrationResults>((resolve) => {
|
| 62 |
-
;(stop as any)._resolver = resolve
|
| 63 |
-
})
|
| 64 |
-
const stop = () => {
|
| 65 |
-
clearInterval(intervalId)
|
| 66 |
-
onProgress("MOCK: Calibration recording finished.")
|
| 67 |
-
const finalResults = MOCK_MOTOR_NAMES.reduce((acc, name) => {
|
| 68 |
-
acc[name] = { min: liveData[name].min, max: liveData[name].max }
|
| 69 |
-
return acc
|
| 70 |
-
}, {} as WebCalibrationResults)
|
| 71 |
-
;(stop as any)._resolver(finalResults)
|
| 72 |
-
}
|
| 73 |
-
return { result: resultPromise, stop }
|
| 74 |
-
},
|
| 75 |
-
teleoperate: async (
|
| 76 |
-
robot: RobotConnection,
|
| 77 |
-
{
|
| 78 |
-
calibrationData,
|
| 79 |
-
onStateUpdate,
|
| 80 |
-
}: { calibrationData: WebCalibrationResults; onStateUpdate: (state: TeleoperationState) => void },
|
| 81 |
-
): Promise<{
|
| 82 |
-
start: () => void
|
| 83 |
-
stop: () => void
|
| 84 |
-
updateKeyState: (key: string, pressed: boolean) => void
|
| 85 |
-
moveMotor: (motorName: string, position: number) => void
|
| 86 |
-
}> => {
|
| 87 |
-
let isActive = false
|
| 88 |
-
let intervalId: NodeJS.Timeout
|
| 89 |
-
const keyStates: { [key: string]: { pressed: boolean } } = {}
|
| 90 |
-
const motorConfigs = MOCK_MOTOR_NAMES.map((name) => ({
|
| 91 |
-
name,
|
| 92 |
-
currentPosition: calibrationData[name] ? (calibrationData[name].min + calibrationData[name].max) / 2 : 2048,
|
| 93 |
-
minPosition: calibrationData[name]?.min ?? 0,
|
| 94 |
-
maxPosition: calibrationData[name]?.max ?? 4095,
|
| 95 |
-
}))
|
| 96 |
-
const teleopState: TeleoperationState = { isActive, motorConfigs, keyStates }
|
| 97 |
-
const updateLoop = () => onStateUpdate({ ...teleopState, motorConfigs: [...motorConfigs] })
|
| 98 |
-
return {
|
| 99 |
-
start: () => {
|
| 100 |
-
if (isActive) return
|
| 101 |
-
isActive = true
|
| 102 |
-
teleopState.isActive = true
|
| 103 |
-
intervalId = setInterval(updateLoop, 100)
|
| 104 |
-
onStateUpdate({ ...teleopState })
|
| 105 |
-
},
|
| 106 |
-
stop: () => {
|
| 107 |
-
if (!isActive) return
|
| 108 |
-
isActive = false
|
| 109 |
-
teleopState.isActive = false
|
| 110 |
-
clearInterval(intervalId)
|
| 111 |
-
onStateUpdate({ ...teleopState })
|
| 112 |
-
},
|
| 113 |
-
updateKeyState: (key: string, pressed: boolean) => {
|
| 114 |
-
keyStates[key] = { pressed }
|
| 115 |
-
teleopState.keyStates = { ...keyStates }
|
| 116 |
-
onStateUpdate({ ...teleopState })
|
| 117 |
-
},
|
| 118 |
-
moveMotor: (motorName: string, position: number) => {
|
| 119 |
-
const motor = motorConfigs.find((m) => m.name === motorName)
|
| 120 |
-
if (motor) {
|
| 121 |
-
motor.currentPosition = position
|
| 122 |
-
onStateUpdate({ ...teleopState, motorConfigs: [...motorConfigs] })
|
| 123 |
-
}
|
| 124 |
-
},
|
| 125 |
-
}
|
| 126 |
-
},
|
| 127 |
-
SO100_KEYBOARD_CONTROLS: {
|
| 128 |
-
shoulder_pan: { positive: "ArrowRight", negative: "ArrowLeft" },
|
| 129 |
-
shoulder_lift: { positive: "ArrowUp", negative: "ArrowDown" },
|
| 130 |
-
elbow_flex: { positive: "w", negative: "s" },
|
| 131 |
-
wrist_flex: { positive: "a", negative: "d" },
|
| 132 |
-
wrist_roll: { positive: "q", negative: "e" },
|
| 133 |
-
gripper: { positive: "o", negative: "c" },
|
| 134 |
-
stop: "Escape",
|
| 135 |
-
},
|
| 136 |
-
MOCK_MOTOR_NAMES,
|
| 137 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
examples/cyberpunk-standalone/src/lib/unified-storage.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Unified storage for robot device data and calibration
|
| 3 |
+
* Manages device persistence using localStorage with serial numbers as keys
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
import type { WebCalibrationResults } from "@lerobot/web";
|
| 7 |
+
|
| 8 |
+
export interface DeviceInfo {
|
| 9 |
+
serialNumber: string;
|
| 10 |
+
robotType: string;
|
| 11 |
+
robotId: string;
|
| 12 |
+
usbMetadata?: {
|
| 13 |
+
vendorId?: number;
|
| 14 |
+
productId?: number;
|
| 15 |
+
serialNumber?: string;
|
| 16 |
+
manufacturer?: string;
|
| 17 |
+
product?: string;
|
| 18 |
+
};
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export interface CalibrationMetadata {
|
| 22 |
+
timestamp: string;
|
| 23 |
+
readCount: number;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
export interface UnifiedRobotData {
|
| 27 |
+
device_info: DeviceInfo;
|
| 28 |
+
calibration?: WebCalibrationResults & {
|
| 29 |
+
device_type?: string;
|
| 30 |
+
device_id?: string;
|
| 31 |
+
calibrated_at?: string;
|
| 32 |
+
platform?: string;
|
| 33 |
+
api?: string;
|
| 34 |
+
};
|
| 35 |
+
calibration_metadata?: CalibrationMetadata;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
export function getUnifiedRobotData(
|
| 39 |
+
serialNumber: string
|
| 40 |
+
): UnifiedRobotData | null {
|
| 41 |
+
try {
|
| 42 |
+
const key = `lerobotjs-${serialNumber}`;
|
| 43 |
+
const data = localStorage.getItem(key);
|
| 44 |
+
if (!data) return null;
|
| 45 |
+
|
| 46 |
+
const parsed = JSON.parse(data);
|
| 47 |
+
return parsed as UnifiedRobotData;
|
| 48 |
+
} catch (error) {
|
| 49 |
+
console.warn(`Failed to load robot data for ${serialNumber}:`, error);
|
| 50 |
+
return null;
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
export function saveUnifiedRobotData(
|
| 55 |
+
serialNumber: string,
|
| 56 |
+
data: UnifiedRobotData
|
| 57 |
+
): void {
|
| 58 |
+
try {
|
| 59 |
+
const key = `lerobotjs-${serialNumber}`;
|
| 60 |
+
localStorage.setItem(key, JSON.stringify(data));
|
| 61 |
+
} catch (error) {
|
| 62 |
+
console.warn(`Failed to save robot data for ${serialNumber}:`, error);
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
export function saveCalibrationData(
|
| 67 |
+
serialNumber: string,
|
| 68 |
+
calibrationData: WebCalibrationResults,
|
| 69 |
+
metadata: CalibrationMetadata
|
| 70 |
+
): void {
|
| 71 |
+
try {
|
| 72 |
+
const existingData = getUnifiedRobotData(serialNumber);
|
| 73 |
+
if (!existingData) {
|
| 74 |
+
console.warn(
|
| 75 |
+
`No device info found for ${serialNumber}, cannot save calibration`
|
| 76 |
+
);
|
| 77 |
+
return;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
const updatedData: UnifiedRobotData = {
|
| 81 |
+
...existingData,
|
| 82 |
+
calibration: calibrationData,
|
| 83 |
+
calibration_metadata: metadata,
|
| 84 |
+
};
|
| 85 |
+
|
| 86 |
+
saveUnifiedRobotData(serialNumber, updatedData);
|
| 87 |
+
} catch (error) {
|
| 88 |
+
console.warn(`Failed to save calibration data for ${serialNumber}:`, error);
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
export function saveDeviceInfo(
|
| 93 |
+
serialNumber: string,
|
| 94 |
+
deviceInfo: DeviceInfo
|
| 95 |
+
): void {
|
| 96 |
+
try {
|
| 97 |
+
const existingData = getUnifiedRobotData(serialNumber);
|
| 98 |
+
const updatedData: UnifiedRobotData = {
|
| 99 |
+
...existingData,
|
| 100 |
+
device_info: deviceInfo,
|
| 101 |
+
};
|
| 102 |
+
|
| 103 |
+
saveUnifiedRobotData(serialNumber, updatedData);
|
| 104 |
+
} catch (error) {
|
| 105 |
+
console.warn(`Failed to save device info for ${serialNumber}:`, error);
|
| 106 |
+
}
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
export function getAllSavedRobots(): DeviceInfo[] {
|
| 110 |
+
try {
|
| 111 |
+
const robots: DeviceInfo[] = [];
|
| 112 |
+
|
| 113 |
+
for (let i = 0; i < localStorage.length; i++) {
|
| 114 |
+
const key = localStorage.key(i);
|
| 115 |
+
if (key?.startsWith("lerobotjs-")) {
|
| 116 |
+
const data = getUnifiedRobotData(key.replace("lerobotjs-", ""));
|
| 117 |
+
if (data?.device_info) {
|
| 118 |
+
robots.push(data.device_info);
|
| 119 |
+
}
|
| 120 |
+
}
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
return robots;
|
| 124 |
+
} catch (error) {
|
| 125 |
+
console.warn("Failed to load saved robots:", error);
|
| 126 |
+
return [];
|
| 127 |
+
}
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
export function removeRobotData(serialNumber: string): void {
|
| 131 |
+
try {
|
| 132 |
+
const key = `lerobotjs-${serialNumber}`;
|
| 133 |
+
localStorage.removeItem(key);
|
| 134 |
+
} catch (error) {
|
| 135 |
+
console.warn(`Failed to remove robot data for ${serialNumber}:`, error);
|
| 136 |
+
}
|
| 137 |
+
}
|
examples/cyberpunk-standalone/src/types/robot.ts
CHANGED
|
@@ -1,35 +1,6 @@
|
|
| 1 |
-
export
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
serialNumber?: string
|
| 8 |
-
}
|
| 9 |
-
|
| 10 |
-
export interface LiveCalibrationData {
|
| 11 |
-
[motorName: string]: {
|
| 12 |
-
current: number
|
| 13 |
-
min: number
|
| 14 |
-
max: number
|
| 15 |
-
range: number
|
| 16 |
-
}
|
| 17 |
-
}
|
| 18 |
-
|
| 19 |
-
export interface WebCalibrationResults {
|
| 20 |
-
[motorName: string]: {
|
| 21 |
-
min: number
|
| 22 |
-
max: number
|
| 23 |
-
}
|
| 24 |
-
}
|
| 25 |
-
|
| 26 |
-
export interface TeleoperationState {
|
| 27 |
-
isActive: boolean
|
| 28 |
-
motorConfigs: Array<{
|
| 29 |
-
name: string
|
| 30 |
-
currentPosition: number
|
| 31 |
-
minPosition: number
|
| 32 |
-
maxPosition: number
|
| 33 |
-
}>
|
| 34 |
-
keyStates: { [key: string]: { pressed: boolean } }
|
| 35 |
-
}
|
|
|
|
| 1 |
+
export type {
|
| 2 |
+
RobotConnection,
|
| 3 |
+
LiveCalibrationData,
|
| 4 |
+
WebCalibrationResults,
|
| 5 |
+
TeleoperationState,
|
| 6 |
+
} from "@lerobot/web";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
examples/cyberpunk-standalone/tsconfig.tsbuildinfo
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|