Spaces:
Running
Running
refactor: calibrate for web
Browse files- docs/api-comparison.md +194 -0
- docs/planning/005_same_api.md +447 -0
- src/demo/App.tsx +2 -2
- src/demo/components/CalibrationPanel.tsx +190 -369
- src/demo/components/PortManager.tsx +47 -45
- src/demo/components/TeleoperationPanel.tsx +50 -23
- src/demo/hooks/useTeleoperation.ts +35 -3
- src/demo/pages/Home.tsx +6 -6
- src/demo/types.ts +2 -20
- src/lerobot/web/calibrate.ts +136 -704
- src/lerobot/web/find_port.ts +273 -194
- src/lerobot/web/robot-connection.ts +1 -1
- src/lerobot/web/robots/so100_config.ts +53 -0
- src/lerobot/web/teleoperate.ts +2 -1
- src/lerobot/web/utils/motor-calibration.ts +183 -0
- src/lerobot/web/{motor-utils.ts → utils/motor-communication.ts} +71 -55
- src/lerobot/web/utils/serial-port-wrapper.ts +72 -0
- src/lerobot/web/utils/sign-magnitude.ts +41 -0
- src/lerobot/web/utils/sts3215-protocol.ts +34 -0
- tsconfig.json +3 -0
docs/api-comparison.md
ADDED
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# lerobot API Comparison: Python vs Node.js vs Web
|
2 |
+
|
3 |
+
This document provides a comprehensive three-way comparison of lerobot APIs across Python lerobot (original), Node.js lerobot.js, and Web lerobot.js platforms.
|
4 |
+
|
5 |
+
## 🔄 Core Function Comparison
|
6 |
+
|
7 |
+
| Function Category | Python lerobot (Original) | Node.js lerobot.js | Web Browser lerobot.js | Key Pattern |
|
8 |
+
| ------------------ | ------------------------- | ----------------------------- | ------------------------------------------------------ | ------------------------------------------------------ |
|
9 |
+
| **Port Discovery** | `find_port()` | `findPort()` | `findPortWeb(logger)` | Python → Node.js: Direct port, Web: Requires UI logger |
|
10 |
+
| **Robot Creation** | `SO100Follower(config)` | `createSO100Follower(config)` | `createWebTeleoperationController(port, serialNumber)` | Python: Class, Node.js: Factory, Web: Pre-opened port |
|
11 |
+
| **Calibration** | `calibrate(cfg)` | `calibrate(config)` | `createCalibrationController(armType, port)` | Python/Node.js: Function, Web: Controller pattern |
|
12 |
+
| **Teleoperation** | `teleoperate(cfg)` | `teleoperate(config)` | `createWebTeleoperationController(port, serialNumber)` | Python/Node.js: Function, Web: Manual state management |
|
13 |
+
|
14 |
+
## 📋 Detailed API Reference
|
15 |
+
|
16 |
+
### Port Discovery
|
17 |
+
|
18 |
+
| Aspect | Python lerobot | Node.js lerobot.js | Web Browser lerobot.js |
|
19 |
+
| -------------------- | ----------------------------------------- | -------------------------------------------- | ---------------------------------------------- |
|
20 |
+
| **Function** | `find_port()` | `findPort()` | `findPortWeb(logger)` |
|
21 |
+
| **Import** | `from lerobot.find_port import find_port` | `import { findPort } from 'lerobot.js/node'` | `import { findPortWeb } from 'lerobot.js/web'` |
|
22 |
+
| **Parameters** | None | None | `logger: (message: string) => void` |
|
23 |
+
| **User Interaction** | Terminal prompts via `input()` | Terminal prompts via readline | Browser modals and buttons |
|
24 |
+
| **Port Access** | Direct system access via pyserial | Direct system access | Web Serial API permissions |
|
25 |
+
| **Return Value** | None (prints to console) | None (prints to console) | None (calls logger function) |
|
26 |
+
| **Example** | `python<br>find_port()<br>` | `js<br>await findPort();<br>` | `js<br>await findPortWeb(console.log);<br>` |
|
27 |
+
|
28 |
+
### Robot Connection & Creation
|
29 |
+
|
30 |
+
| Aspect | Python lerobot | Node.js lerobot.js | Web Browser lerobot.js |
|
31 |
+
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
32 |
+
| **Creation** | `SO100Follower(config)` or `make_robot_from_config(config)` | `createSO100Follower(config)` | `createWebTeleoperationController(port, serialNumber)` |
|
33 |
+
| **Connection** | `robot.connect()` | `await robot.connect()` | Port already opened before creation |
|
34 |
+
| **Port Parameter** | `RobotConfig(port='/dev/ttyUSB0')` | `{ port: 'COM4' }` (string) | `SerialPort` object |
|
35 |
+
| **Baud Rate** | Handled internally | Handled internally | `await port.open({ baudRate: 1000000 })` |
|
36 |
+
| **Factory Pattern** | `make_robot_from_config()` factory | `createSO100Follower()` factory | `createWebTeleoperationController()` factory |
|
37 |
+
| **Example** | `python<br>from lerobot.common.robots.so100_follower import SO100Follower<br>robot = SO100Follower(config)<br>robot.connect()<br>` | `js<br>const robot = createSO100Follower({<br> type: 'so100_follower',<br> port: 'COM4',<br> id: 'my_robot'<br>});<br>await robot.connect();<br>` | `js<br>const port = await navigator.serial.requestPort();<br>await port.open({ baudRate: 1000000 });<br>const robot = await createWebTeleoperationController(<br> port, 'my_robot'<br>);<br>` |
|
38 |
+
|
39 |
+
### Calibration
|
40 |
+
|
41 |
+
| Aspect | Python lerobot | Node.js lerobot.js | Web Browser lerobot.js |
|
42 |
+
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
43 |
+
| **Main Function** | `calibrate(cfg)` | `calibrate(config)` | `createCalibrationController(armType, port)` |
|
44 |
+
| **Import** | `from lerobot.calibrate import calibrate` | `import { calibrate } from 'lerobot.js/node'` | `import { createCalibrationController } from 'lerobot.js/web'` |
|
45 |
+
| **Configuration** | `CalibrateConfig` dataclass | Single config object | Controller with methods |
|
46 |
+
| **Workflow** | All-in-one function calls `device.calibrate()` | All-in-one function | Step-by-step methods |
|
47 |
+
| **Device Pattern** | Creates device, calls `device.calibrate()`, `device.disconnect()` | Automatic within calibrate() | Manual controller lifecycle |
|
48 |
+
| **Homing** | Automatic within `device.calibrate()` | Automatic within calibrate() | `await controller.performHomingStep()` |
|
49 |
+
| **Range Recording** | Automatic within `device.calibrate()` | Automatic within calibrate() | `await controller.performRangeRecordingStep()` |
|
50 |
+
| **Completion** | Automatic save and disconnect | Automatic save | `await controller.finishCalibration()` |
|
51 |
+
| **Data Storage** | File system (HF cache) | File system (HF cache) | localStorage + file download |
|
52 |
+
| **Example** | `python<br>from lerobot.calibrate import calibrate<br>from lerobot.common.robots.so100_follower import SO100FollowerConfig<br>calibrate(CalibrateConfig(<br> robot=SO100FollowerConfig(<br> port='/dev/ttyUSB0', id='my_robot'<br> )<br>))<br>` | `js<br>await calibrate({<br> robot: {<br> type: 'so100_follower',<br> port: 'COM4',<br> id: 'my_robot'<br> }<br>});<br>` | `js<br>const controller = await createCalibrationController(<br> 'so100_follower', port<br>);<br>await controller.performHomingStep();<br>await controller.performRangeRecordingStep(stopCondition);<br>const results = await controller.finishCalibration();<br>` |
|
53 |
+
|
54 |
+
### Teleoperation
|
55 |
+
|
56 |
+
| Aspect | Python lerobot | Node.js lerobot.js | Web Browser lerobot.js |
|
57 |
+
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
58 |
+
| **Main Function** | `teleoperate(cfg)` | `teleoperate(config)` | `createWebTeleoperationController(port, serialNumber)` |
|
59 |
+
| **Import** | `from lerobot.teleoperate import teleoperate` | `import { teleoperate } from 'lerobot.js/node'` | `import { createWebTeleoperationController } from 'lerobot.js/web'` |
|
60 |
+
| **Control Loop** | `teleop_loop()` with `get_action()` and `send_action()` | Automatic 60 FPS loop | Manual start/stop with `controller.start()` |
|
61 |
+
| **Device Management** | Creates teleop and robot devices, connects both | Device creation handled internally | Port opened externally, controller manages state |
|
62 |
+
| **Input Handling** | Teleoperator `get_action()` method | Terminal raw mode | Browser event listeners |
|
63 |
+
| **Key State** | Handled by teleoperator device | Internal management | `controller.updateKeyState(key, pressed)` |
|
64 |
+
| **Configuration** | `TeleoperateConfig` with separate robot/teleop configs | `js<br>{<br> robot: { type, port, id },<br> teleop: { type: 'keyboard' },<br> fps: 60,<br> step_size: 25<br>}<br>` | Built into controller |
|
65 |
+
| **Example** | `python<br>from lerobot.teleoperate import teleoperate<br>teleoperate(TeleoperateConfig(<br> robot=SO100FollowerConfig(port='/dev/ttyUSB0'),<br> teleop=SO100LeaderConfig(port='/dev/ttyUSB1')<br>))<br>` | `js<br>await teleoperate({<br> robot: {<br> type: 'so100_follower',<br> port: 'COM4',<br> id: 'my_robot'<br> },<br> teleop: { type: 'keyboard' }<br>});<br>` | `js<br>const controller = await createWebTeleoperationController(<br> port, 'my_robot'<br>);<br>controller.start();<br>// Handle keyboard events manually<br>document.addEventListener('keydown', (e) => {<br> controller.updateKeyState(e.key, true);<br>});<br>` |
|
66 |
+
|
67 |
+
### Motor Control
|
68 |
+
|
69 |
+
| Aspect | Python lerobot | Node.js lerobot.js | Web Browser lerobot.js |
|
70 |
+
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
71 |
+
| **Get Positions** | `robot.get_observation()` (includes motor positions) | `await robot.getMotorPositions()` | `controller.getMotorConfigs()` or `controller.getState()` |
|
72 |
+
| **Set Positions** | `robot.send_action(action)` (dict format) | `await robot.setMotorPositions(positions)` | `await controller.setMotorPositions(positions)` |
|
73 |
+
| **Position Format** | `dict[str, float]` (action format) | `Record<string, number>` | `Record<string, number>` ✅ Same |
|
74 |
+
| **Data Flow** | `get_observation()` → `send_action()` loop | Direct position get/set methods | Controller state management |
|
75 |
+
| **Action Features** | `robot.action_features` (motor names) | Motor names hardcoded in implementation | Motor configs with metadata |
|
76 |
+
| **Calibration Limits** | Handled in robot implementation | `robot.getCalibrationLimits()` | `controller.getMotorConfigs()` (includes limits) |
|
77 |
+
| **Home Position** | Manual via action dict | Manual calculation | `await controller.goToHomePosition()` |
|
78 |
+
| **Example** | `python<br>obs = robot.get_observation()<br>action = {motor: value for motor in robot.action_features}<br>robot.send_action(action)<br>` | `js<br>const positions = await robot.getMotorPositions();<br>await robot.setMotorPositions({<br> shoulder_pan: 2047,<br> shoulder_lift: 1800<br>});<br>` | `js<br>const state = controller.getState();<br>await controller.setMotorPositions({<br> shoulder_pan: 2047,<br> shoulder_lift: 1800<br>});<br>` |
|
79 |
+
|
80 |
+
### Calibration Data Management
|
81 |
+
|
82 |
+
| Aspect | Node.js | Web Browser |
|
83 |
+
| -------------------- | ------------------------------------------- | ------------------------------------- |
|
84 |
+
| **Storage Location** | `~/.cache/huggingface/lerobot/calibration/` | `localStorage` + file download |
|
85 |
+
| **File Format** | JSON files on disk | JSON in browser storage |
|
86 |
+
| **Loading** | Automatic during `robot.connect()` | Manual via `loadCalibrationConfig()` |
|
87 |
+
| **Saving** | `robot.saveCalibration(results)` | `saveCalibrationResults()` + download |
|
88 |
+
| **Persistence** | Permanent until deleted | Browser-specific, can be cleared |
|
89 |
+
| **Sharing** | File system sharing | Manual file sharing |
|
90 |
+
|
91 |
+
### Error Handling & Debugging
|
92 |
+
|
93 |
+
| Aspect | Node.js | Web Browser |
|
94 |
+
| --------------------- | ------------------------ | ------------------------------------ |
|
95 |
+
| **Connection Errors** | Standard Node.js errors | Web Serial API errors |
|
96 |
+
| **Permission Issues** | File system permissions | User permission prompts |
|
97 |
+
| **Port Conflicts** | "Port in use" errors | Silent failures or permission errors |
|
98 |
+
| **Debugging** | Console.log + terminal | Browser DevTools console |
|
99 |
+
| **Logging** | Built-in terminal output | Passed logger functions |
|
100 |
+
|
101 |
+
## 🎯 Usage Pattern Summary
|
102 |
+
|
103 |
+
### Python lerobot (Original) - Research & Production
|
104 |
+
|
105 |
+
```python
|
106 |
+
# Configuration-driven, device-based approach
|
107 |
+
from lerobot.find_port import find_port
|
108 |
+
from lerobot.calibrate import calibrate, CalibrateConfig
|
109 |
+
from lerobot.teleoperate import teleoperate, TeleoperateConfig
|
110 |
+
from lerobot.common.robots.so100_follower import SO100FollowerConfig
|
111 |
+
from lerobot.common.teleoperators.so100_leader import SO100LeaderConfig
|
112 |
+
|
113 |
+
# Find port
|
114 |
+
find_port()
|
115 |
+
|
116 |
+
# Calibrate robot
|
117 |
+
calibrate(CalibrateConfig(
|
118 |
+
robot=SO100FollowerConfig(port='/dev/ttyUSB0', id='my_robot')
|
119 |
+
))
|
120 |
+
|
121 |
+
# Teleoperation with leader-follower
|
122 |
+
teleoperate(TeleoperateConfig(
|
123 |
+
robot=SO100FollowerConfig(port='/dev/ttyUSB0'),
|
124 |
+
teleop=SO100LeaderConfig(port='/dev/ttyUSB1')
|
125 |
+
))
|
126 |
+
```
|
127 |
+
|
128 |
+
### Node.js lerobot.js - Server/Desktop Applications
|
129 |
+
|
130 |
+
```javascript
|
131 |
+
// High-level, all-in-one functions (mirrors Python closely)
|
132 |
+
import { findPort, calibrate, teleoperate } from "lerobot.js/node";
|
133 |
+
|
134 |
+
await findPort();
|
135 |
+
await calibrate({
|
136 |
+
robot: { type: "so100_follower", port: "COM4", id: "my_robot" },
|
137 |
+
});
|
138 |
+
await teleoperate({
|
139 |
+
robot: { type: "so100_follower", port: "COM4" },
|
140 |
+
teleop: { type: "keyboard" },
|
141 |
+
});
|
142 |
+
```
|
143 |
+
|
144 |
+
### Web Browser lerobot.js - Interactive Applications
|
145 |
+
|
146 |
+
```javascript
|
147 |
+
// Controller-based, step-by-step approach (browser constraints)
|
148 |
+
import {
|
149 |
+
findPortWeb,
|
150 |
+
createCalibrationController,
|
151 |
+
createWebTeleoperationController,
|
152 |
+
} from "lerobot.js/web";
|
153 |
+
|
154 |
+
// User interaction required
|
155 |
+
const port = await navigator.serial.requestPort();
|
156 |
+
await port.open({ baudRate: 1000000 });
|
157 |
+
|
158 |
+
// Step-by-step calibration
|
159 |
+
const calibrator = await createCalibrationController("so100_follower", port);
|
160 |
+
await calibrator.performHomingStep();
|
161 |
+
await calibrator.performRangeRecordingStep(() => stopRecording);
|
162 |
+
|
163 |
+
// Manual teleoperation control
|
164 |
+
const controller = await createWebTeleoperationController(port, "my_robot");
|
165 |
+
controller.start();
|
166 |
+
```
|
167 |
+
|
168 |
+
## 🔑 Key Architectural Differences
|
169 |
+
|
170 |
+
1. **User Interaction Model**
|
171 |
+
|
172 |
+
- **Node.js**: Terminal-based with readline prompts
|
173 |
+
- **Web**: Browser UI with buttons and modals
|
174 |
+
|
175 |
+
2. **Permission Model**
|
176 |
+
|
177 |
+
- **Node.js**: System-level permissions
|
178 |
+
- **Web**: User-granted permissions per device
|
179 |
+
|
180 |
+
3. **State Management**
|
181 |
+
|
182 |
+
- **Node.js**: Function-based, stateless
|
183 |
+
- **Web**: Controller-based, stateful
|
184 |
+
|
185 |
+
4. **Data Persistence**
|
186 |
+
|
187 |
+
- **Node.js**: File system with cross-session persistence
|
188 |
+
- **Web**: Browser storage with limited persistence
|
189 |
+
|
190 |
+
5. **Platform Integration**
|
191 |
+
- **Node.js**: Deep system integration
|
192 |
+
- **Web**: Security-constrained browser environment
|
193 |
+
|
194 |
+
This comparison helps developers choose the right platform and understand the API differences when porting between Node.js and Web implementations.
|
docs/planning/005_same_api.md
ADDED
@@ -0,0 +1,447 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# User Story 005: Building Block API - Clean, Composable, Unified
|
2 |
+
|
3 |
+
## 🎯 Goal
|
4 |
+
|
5 |
+
Create a clean building block API across Python lerobot, Node.js lerobot.js, and Web lerobot.js where users manage state and workflow while we abstract away robotics complexity. Users learn core concepts once and apply them everywhere.
|
6 |
+
|
7 |
+
## 🚨 Problem Statement
|
8 |
+
|
9 |
+
Currently, the APIs have fundamentally different approaches:
|
10 |
+
|
11 |
+
### Current Inconsistencies
|
12 |
+
|
13 |
+
1. **State Management**:
|
14 |
+
- Python/Node.js: Functions handle connection lifecycle internally
|
15 |
+
- Web: User must manually manage controllers and connections
|
16 |
+
2. **Complexity Exposure**:
|
17 |
+
- Python/Node.js: `calibrate(config)` - single call does everything
|
18 |
+
- Web: Multi-step `controller.performHomingStep()`, `controller.moveToPosition()`, etc.
|
19 |
+
3. **Resource Management**:
|
20 |
+
- Python/Node.js: Each function connects/disconnects automatically
|
21 |
+
- Web: User manages port lifecycle manually
|
22 |
+
4. **Device Identification**:
|
23 |
+
- Node.js: Port discovery finds system ports by name (COM4, /dev/ttyUSB0)
|
24 |
+
- Web: No device identification - WebSerial can't distinguish between identical devices
|
25 |
+
|
26 |
+
### User Pain Points
|
27 |
+
|
28 |
+
- **Different Mental Models**: Same operations require different approaches per platform
|
29 |
+
- **Web Complexity Exposure**: Users must understand servo protocols and multi-step procedures
|
30 |
+
- **Inconsistent Resource Management**: Some platforms auto-manage, others don't
|
31 |
+
- **Device Identification Issues**: Web can't identify which physical robot is connected
|
32 |
+
- **Hidden State Problems**: Users can't control when connections happen or track device status
|
33 |
+
|
34 |
+
## 🎯 Success Criteria
|
35 |
+
|
36 |
+
### Primary Goal: Building Block Consistency
|
37 |
+
|
38 |
+
- [ ] **Same Operation Patterns**: All platforms use identical patterns for core operations
|
39 |
+
- [ ] **Clean Responsibility Split**: Users manage state/UI, library handles robotics
|
40 |
+
- [ ] **Self-Contained Operations**: Each function manages its own connection lifecycle
|
41 |
+
- [ ] **Unified Control Model**: Long-running operations use same session-based control
|
42 |
+
|
43 |
+
### Secondary Goal: Platform-Appropriate Implementation
|
44 |
+
|
45 |
+
- [ ] **Smart Device Pairing**: Web uses WebUSB + WebSerial for device identification + communication
|
46 |
+
- [ ] **Hidden Robotics Complexity**: Baud rates, protocols, servo commands abstracted away
|
47 |
+
- [ ] **Graceful Resource Management**: Automatic connection cleanup with user control
|
48 |
+
- [ ] **Consistent Error Handling**: Platform-specific errors mapped to common patterns
|
49 |
+
|
50 |
+
## 📋 Ground Truth Decisions
|
51 |
+
|
52 |
+
### 1. Building Block Philosophy
|
53 |
+
|
54 |
+
**DECIDED: User manages state, we provide clean tools**
|
55 |
+
|
56 |
+
✅ **User Responsibilities:**
|
57 |
+
|
58 |
+
- Device pairing and storage (when to pair, how to persist)
|
59 |
+
- UI workflow (when to show dialogs, how to display progress)
|
60 |
+
- State management (which devices they have, tracking connections)
|
61 |
+
- Error handling UI (how to display errors to end users)
|
62 |
+
|
63 |
+
✅ **Library Responsibilities:**
|
64 |
+
|
65 |
+
- WebUSB + WebSerial pairing dialogs (native browser interactions)
|
66 |
+
- Baud rate configuration (1000000 for SO-100)
|
67 |
+
- STS3215 servo protocol implementation
|
68 |
+
- Calibration and teleoperation procedures
|
69 |
+
- Resource conflict prevention with clear error messages
|
70 |
+
|
71 |
+
### 2. Device Pairing vs Connection Pattern
|
72 |
+
|
73 |
+
**DECIDED: Separate pairing (once) from connection (per operation)**
|
74 |
+
|
75 |
+
```javascript
|
76 |
+
// PAIRING: One-time device identification (stores device reference)
|
77 |
+
const deviceInfo = await lerobot.pairDevice();
|
78 |
+
// User stores this: localStorage, state management, etc.
|
79 |
+
|
80 |
+
// OPERATIONS: Each function connects internally using device reference
|
81 |
+
const session = await lerobot.calibrate({ robot: { device: deviceInfo } });
|
82 |
+
await session.stop(); // Graceful disconnect
|
83 |
+
|
84 |
+
const session2 = await lerobot.teleoperate({ robot: { device: deviceInfo } });
|
85 |
+
await session2.stop(); // Independent connection lifecycle
|
86 |
+
```
|
87 |
+
|
88 |
+
**Web Implementation Details:**
|
89 |
+
|
90 |
+
- `pairDevice()` shows WebUSB dialog → gets device info → shows WebSerial dialog → extracts serial ID
|
91 |
+
- Each operation connects to paired device internally (no singleton state)
|
92 |
+
- Users never see servo protocols, baud rates, or connection management
|
93 |
+
|
94 |
+
### 3. Connection Lifecycle Pattern
|
95 |
+
|
96 |
+
**DECIDED: Each operation is self-contained (like Python/Node.js)**
|
97 |
+
|
98 |
+
```javascript
|
99 |
+
// PYTHON/NODE.JS CURRENT PATTERN (keep this):
|
100 |
+
await calibrate(config); // Internally: connect → calibrate → disconnect
|
101 |
+
await teleoperate(config); // Internally: connect → teleoperate → disconnect
|
102 |
+
|
103 |
+
// WEB NEW PATTERN (match Python/Node.js):
|
104 |
+
const cal = await calibrate({ robot: { device: pairedDevice } }); // connect → start calibration
|
105 |
+
await cal.stop(); // disconnect
|
106 |
+
|
107 |
+
const tel = await teleoperate({ robot: { device: pairedDevice } }); // connect → start teleoperation
|
108 |
+
await tel.stop(); // disconnect
|
109 |
+
```
|
110 |
+
|
111 |
+
**Key Principle:** No shared connections, no singletons, each operation manages its own lifecycle.
|
112 |
+
|
113 |
+
### 4. Long-Running Operation Control
|
114 |
+
|
115 |
+
**DECIDED: Both calibrate and teleoperate are long-running until user stops**
|
116 |
+
|
117 |
+
```javascript
|
118 |
+
// UNIFIED SESSION PATTERN for both operations:
|
119 |
+
interface RobotSession {
|
120 |
+
stop(): Promise<void>; // Graceful shutdown + disconnect
|
121 |
+
getState(): SessionState; // Current operation state
|
122 |
+
}
|
123 |
+
|
124 |
+
// Both operations return controllable sessions:
|
125 |
+
const calibration = await lerobot.calibrate({
|
126 |
+
robot: { device: myDevice },
|
127 |
+
onStateChange: (state) => updateUI(state),
|
128 |
+
});
|
129 |
+
|
130 |
+
const teleoperation = await lerobot.teleoperate({
|
131 |
+
robot: { device: myDevice },
|
132 |
+
onStateChange: (state) => updateUI(state),
|
133 |
+
});
|
134 |
+
|
135 |
+
// Same control pattern for both:
|
136 |
+
await calibration.stop();
|
137 |
+
await teleoperation.stop();
|
138 |
+
```
|
139 |
+
|
140 |
+
**Calibrate Reality Check:**
|
141 |
+
|
142 |
+
- Starts calibration procedure
|
143 |
+
- Waits for user to move robot through ranges
|
144 |
+
- Records positions until user decides to stop
|
145 |
+
- Can run indefinitely like teleoperate
|
146 |
+
|
147 |
+
### 5. Web Device Identification Solution
|
148 |
+
|
149 |
+
**DECIDED: WebUSB + WebSerial both required**
|
150 |
+
|
151 |
+
```javascript
|
152 |
+
// Why both APIs are needed:
|
153 |
+
// 1. WebUSB: Get device serial number → "This is robot arm #ABC123"
|
154 |
+
// 2. WebSerial: Actual communication → Send servo commands
|
155 |
+
|
156 |
+
async function pairDevice(): Promise<DeviceInfo> {
|
157 |
+
// Step 1: WebUSB for device identification
|
158 |
+
const usbDevice = await navigator.usb.requestDevice({...});
|
159 |
+
const serialId = extractSerialNumber(usbDevice);
|
160 |
+
|
161 |
+
// Step 2: WebSerial for communication
|
162 |
+
const serialPort = await navigator.serial.requestPort();
|
163 |
+
await serialPort.open({ baudRate: 1000000 }); // We handle baud rate
|
164 |
+
|
165 |
+
return {
|
166 |
+
serialId,
|
167 |
+
usbDevice,
|
168 |
+
serialPort,
|
169 |
+
type: detectRobotType(usbDevice) // We detect SO-100 vs other robots
|
170 |
+
};
|
171 |
+
}
|
172 |
+
```
|
173 |
+
|
174 |
+
## 🛠️ Implementation Architecture
|
175 |
+
|
176 |
+
### Core Building Blocks
|
177 |
+
|
178 |
+
```javascript
|
179 |
+
// DEVICE PAIRING (Web-specific, one-time)
|
180 |
+
export async function pairDevice(): Promise<DeviceInfo>
|
181 |
+
|
182 |
+
// UNIFIED OPERATIONS (Same across all platforms)
|
183 |
+
export async function calibrate(config: CalibrationConfig): Promise<CalibrationSession>
|
184 |
+
export async function teleoperate(config: TeleoperationConfig): Promise<TeleoperationSession>
|
185 |
+
|
186 |
+
// UNIFIED SESSION CONTROL
|
187 |
+
interface RobotSession {
|
188 |
+
stop(): Promise<void>
|
189 |
+
getState(): SessionState
|
190 |
+
}
|
191 |
+
```
|
192 |
+
|
193 |
+
### Platform-Specific Implementation
|
194 |
+
|
195 |
+
**Node.js (No changes needed - already correct pattern):**
|
196 |
+
|
197 |
+
```javascript
|
198 |
+
// Each function connects/disconnects internally - keep as-is
|
199 |
+
export async function calibrate(config) {
|
200 |
+
const device = createDevice(config.robot);
|
201 |
+
await device.connect();
|
202 |
+
// ... calibration work
|
203 |
+
await device.disconnect();
|
204 |
+
}
|
205 |
+
```
|
206 |
+
|
207 |
+
**Web (New unified wrapper):**
|
208 |
+
|
209 |
+
```javascript
|
210 |
+
// Hide controller complexity behind unified interface
|
211 |
+
export async function calibrate(config) {
|
212 |
+
let connection = null;
|
213 |
+
try {
|
214 |
+
connection = await connectToDevice(config.robot.device); // We handle WebSerial setup
|
215 |
+
const session = new CalibrationSession(connection, config);
|
216 |
+
return session; // User controls session lifecycle
|
217 |
+
} catch (error) {
|
218 |
+
if (connection) await connection.disconnect();
|
219 |
+
throw error;
|
220 |
+
}
|
221 |
+
}
|
222 |
+
```
|
223 |
+
|
224 |
+
## 📋 Detailed API Specification
|
225 |
+
|
226 |
+
### 1. Device Pairing API (Web Only)
|
227 |
+
|
228 |
+
```javascript
|
229 |
+
// Web-specific device pairing
|
230 |
+
interface DeviceInfo {
|
231 |
+
serialId: string; // From WebUSB device descriptor
|
232 |
+
robotType: 'so100_follower' | 'so100_leader';
|
233 |
+
usbVendorId: number;
|
234 |
+
usbProductId: number;
|
235 |
+
// Internal connection details (user doesn't need to know)
|
236 |
+
}
|
237 |
+
|
238 |
+
export async function pairDevice(): Promise<DeviceInfo>
|
239 |
+
```
|
240 |
+
|
241 |
+
**User Workflow:**
|
242 |
+
|
243 |
+
1. Call `pairDevice()` → Native browser dialogs appear
|
244 |
+
2. Store `DeviceInfo` in their state management
|
245 |
+
3. Use stored device for all subsequent operations
|
246 |
+
|
247 |
+
### 2. Unified Calibration API
|
248 |
+
|
249 |
+
```javascript
|
250 |
+
interface CalibrationConfig {
|
251 |
+
robot: {
|
252 |
+
device: DeviceInfo; // Web: paired device, Node.js: { type, port }
|
253 |
+
};
|
254 |
+
onStateChange?: (state: 'connecting' | 'connected' | 'active' | 'stopping' | 'stopped') => void;
|
255 |
+
onProgress?: (progress: CalibrationProgress) => void;
|
256 |
+
}
|
257 |
+
|
258 |
+
interface CalibrationSession extends RobotSession {
|
259 |
+
stop(): Promise<CalibrationResults>; // Returns calibration data
|
260 |
+
}
|
261 |
+
|
262 |
+
// SAME across all platforms:
|
263 |
+
export async function calibrate(config: CalibrationConfig): Promise<CalibrationSession>
|
264 |
+
```
|
265 |
+
|
266 |
+
### 3. Unified Teleoperation API
|
267 |
+
|
268 |
+
```javascript
|
269 |
+
interface TeleoperationConfig {
|
270 |
+
robot: {
|
271 |
+
device: DeviceInfo; // Web: paired device, Node.js: { type, port }
|
272 |
+
};
|
273 |
+
controls?: {
|
274 |
+
[key: string]: ControlMapping;
|
275 |
+
};
|
276 |
+
onStateChange?: (state: SessionState) => void;
|
277 |
+
}
|
278 |
+
|
279 |
+
interface TeleoperationSession extends RobotSession {
|
280 |
+
updateKeyState(key: string, pressed: boolean): void; // For keyboard control
|
281 |
+
getCurrentPositions(): Promise<Record<string, number>>;
|
282 |
+
}
|
283 |
+
|
284 |
+
// SAME across all platforms:
|
285 |
+
export async function teleoperate(config: TeleoperationConfig): Promise<TeleoperationSession>
|
286 |
+
```
|
287 |
+
|
288 |
+
### 4. Error Handling
|
289 |
+
|
290 |
+
```javascript
|
291 |
+
// Standard error types across platforms
|
292 |
+
class DeviceNotPairedError extends Error {}
|
293 |
+
class DeviceConnectionError extends Error {}
|
294 |
+
class DeviceAlreadyConnectedError extends Error {}
|
295 |
+
class CalibrationError extends Error {}
|
296 |
+
class TeleoperationError extends Error {}
|
297 |
+
|
298 |
+
// Platform-specific errors mapped to standard types
|
299 |
+
// Web: WebSerial/WebUSB errors → DeviceConnectionError
|
300 |
+
// Node.js: SerialPort errors → DeviceConnectionError
|
301 |
+
```
|
302 |
+
|
303 |
+
## 🔄 User Experience Flows
|
304 |
+
|
305 |
+
### First-Time Web User
|
306 |
+
|
307 |
+
```javascript
|
308 |
+
// 1. Pair device once (shows native browser dialogs)
|
309 |
+
const myRobot = await lerobot.pairDevice();
|
310 |
+
localStorage.setItem("myRobot", JSON.stringify(myRobot));
|
311 |
+
|
312 |
+
// 2. Use paired device for operations
|
313 |
+
const calibration = await lerobot.calibrate({
|
314 |
+
robot: { device: myRobot },
|
315 |
+
onStateChange: (state) => updateStatus(state),
|
316 |
+
});
|
317 |
+
|
318 |
+
// 3. User controls session
|
319 |
+
await calibration.stop();
|
320 |
+
```
|
321 |
+
|
322 |
+
### Returning Web User
|
323 |
+
|
324 |
+
```javascript
|
325 |
+
// 1. Load stored device (no browser dialogs)
|
326 |
+
const myRobot = JSON.parse(localStorage.getItem("myRobot"));
|
327 |
+
|
328 |
+
// 2. Operations work immediately (connect internally)
|
329 |
+
const teleoperation = await lerobot.teleoperate({
|
330 |
+
robot: { device: myRobot },
|
331 |
+
onStateChange: (state) => updateStatus(state),
|
332 |
+
});
|
333 |
+
```
|
334 |
+
|
335 |
+
### Node.js User (Unchanged)
|
336 |
+
|
337 |
+
```javascript
|
338 |
+
// Same as current - no changes needed
|
339 |
+
const calibration = await lerobot.calibrate({
|
340 |
+
robot: { type: "so100_follower", port: "COM4" },
|
341 |
+
});
|
342 |
+
await calibration.stop();
|
343 |
+
```
|
344 |
+
|
345 |
+
## 🧪 Implementation Phases
|
346 |
+
|
347 |
+
### Phase 1: Core Building Blocks
|
348 |
+
|
349 |
+
- [ ] Define unified interfaces (`RobotSession`, `DeviceInfo`)
|
350 |
+
- [ ] Implement Web `pairDevice()` with WebUSB + WebSerial
|
351 |
+
- [ ] Create session-based wrappers for Web calibration/teleoperation
|
352 |
+
- [ ] Standardize error types and mapping
|
353 |
+
|
354 |
+
### Phase 2: API Unification
|
355 |
+
|
356 |
+
- [ ] Ensure Node.js returns session objects (minimal changes)
|
357 |
+
- [ ] Web operations return sessions that hide controller complexity
|
358 |
+
- [ ] Unified state change callbacks and progress reporting
|
359 |
+
- [ ] Cross-platform testing with real hardware
|
360 |
+
|
361 |
+
### Phase 3: Developer Experience
|
362 |
+
|
363 |
+
- [ ] Update examples to use building block pattern
|
364 |
+
- [ ] Create migration guide from current APIs
|
365 |
+
- [ ] Unified documentation with platform-specific notes
|
366 |
+
- [ ] Performance optimization and bundle size analysis
|
367 |
+
|
368 |
+
## 📊 Success Metrics
|
369 |
+
|
370 |
+
### Developer Experience
|
371 |
+
|
372 |
+
- [ ] **Single Learning Curve**: Core concepts (pairing, sessions, operations) work everywhere
|
373 |
+
- [ ] **User Control**: Developers control state, UI, and workflow completely
|
374 |
+
- [ ] **Platform Abstraction**: Robotics complexity hidden, platform differences minimal
|
375 |
+
|
376 |
+
### Technical Quality
|
377 |
+
|
378 |
+
- [ ] **Resource Management**: No connection leaks, graceful cleanup
|
379 |
+
- [ ] **Error Handling**: Clear, actionable errors with consistent types
|
380 |
+
- [ ] **Performance**: No regressions, efficient resource usage
|
381 |
+
|
382 |
+
### API Consistency
|
383 |
+
|
384 |
+
- [ ] **Same Patterns**: Operations, sessions, and control work identically
|
385 |
+
- [ ] **Platform Appropriate**: Each platform uses native capabilities optimally
|
386 |
+
- [ ] **Migration Path**: Existing code can adopt new patterns incrementally
|
387 |
+
|
388 |
+
## 🔍 Edge Cases & Considerations
|
389 |
+
|
390 |
+
### Multiple Device Management
|
391 |
+
|
392 |
+
```javascript
|
393 |
+
// User manages multiple devices
|
394 |
+
const robot1 = await lerobot.pairDevice(); // First robot
|
395 |
+
const robot2 = await lerobot.pairDevice(); // Second robot
|
396 |
+
|
397 |
+
// Independent sessions
|
398 |
+
const cal1 = await lerobot.calibrate({ robot: { device: robot1 } });
|
399 |
+
const tel2 = await lerobot.teleoperate({ robot: { device: robot2 } });
|
400 |
+
```
|
401 |
+
|
402 |
+
### Resource Conflicts
|
403 |
+
|
404 |
+
```javascript
|
405 |
+
// Library prevents conflicts with clear errors
|
406 |
+
const session1 = await lerobot.calibrate({ robot: { device: myRobot } });
|
407 |
+
|
408 |
+
try {
|
409 |
+
const session2 = await lerobot.teleoperate({ robot: { device: myRobot } });
|
410 |
+
} catch (error) {
|
411 |
+
if (error instanceof DeviceAlreadyConnectedError) {
|
412 |
+
// User gets clear message: "Device already in use by calibration. Stop calibration first."
|
413 |
+
await session1.stop();
|
414 |
+
const session2 = await lerobot.teleoperate({ robot: { device: myRobot } });
|
415 |
+
}
|
416 |
+
}
|
417 |
+
```
|
418 |
+
|
419 |
+
### Browser Tab Management
|
420 |
+
|
421 |
+
```javascript
|
422 |
+
// Sessions automatically cleanup on page unload
|
423 |
+
window.addEventListener("beforeunload", async () => {
|
424 |
+
if (currentSession) {
|
425 |
+
await currentSession.stop(); // Graceful disconnect
|
426 |
+
}
|
427 |
+
});
|
428 |
+
```
|
429 |
+
|
430 |
+
## 📝 Definition of Done
|
431 |
+
|
432 |
+
- [ ] **Web Pairing**: `pairDevice()` uses WebUSB + WebSerial to identify and store device info
|
433 |
+
- [ ] **Session Control**: Both `calibrate()` and `teleoperate()` return controllable sessions
|
434 |
+
- [ ] **Self-Contained Operations**: Each operation connects/disconnects internally, no shared state
|
435 |
+
- [ ] **Unified Error Handling**: Platform-specific errors mapped to consistent error types
|
436 |
+
- [ ] **Resource Management**: Clear error messages for conflicts, automatic cleanup
|
437 |
+
- [ ] **Documentation**: Single API reference with platform-specific implementation notes
|
438 |
+
- [ ] **Migration Path**: Existing users can adopt new patterns without breaking changes
|
439 |
+
- [ ] **Hardware Validation**: Tested with real SO-100 hardware on all platforms
|
440 |
+
|
441 |
+
---
|
442 |
+
|
443 |
+
**Priority**: 🔴 Critical - Blocks adoption due to API inconsistency
|
444 |
+
**Effort**: 🔥🔥🔥 High - Significant Web refactoring, but Node.js mostly unchanged
|
445 |
+
**Impact**: 🚀🚀🚀 Transformative - Enables true cross-platform development
|
446 |
+
|
447 |
+
**Impact**: 🚀🚀🚀 Very High - Transforms user experience and enables widespread adoption
|
src/demo/App.tsx
CHANGED
@@ -1,10 +1,10 @@
|
|
1 |
import React, { useState } from "react";
|
2 |
import { Home } from "./pages/Home";
|
3 |
import { ErrorBoundary } from "./components/ErrorBoundary";
|
4 |
-
import type {
|
5 |
|
6 |
export function App() {
|
7 |
-
const [connectedRobots, setConnectedRobots] = useState<
|
8 |
|
9 |
return (
|
10 |
<ErrorBoundary>
|
|
|
1 |
import React, { useState } from "react";
|
2 |
import { Home } from "./pages/Home";
|
3 |
import { ErrorBoundary } from "./components/ErrorBoundary";
|
4 |
+
import type { RobotConnection } from "../lerobot/web/find_port.js";
|
5 |
|
6 |
export function App() {
|
7 |
+
const [connectedRobots, setConnectedRobots] = useState<RobotConnection[]>([]);
|
8 |
|
9 |
return (
|
10 |
<ErrorBoundary>
|
src/demo/components/CalibrationPanel.tsx
CHANGED
@@ -1,10 +1,4 @@
|
|
1 |
-
import React, {
|
2 |
-
useState,
|
3 |
-
useEffect,
|
4 |
-
useCallback,
|
5 |
-
useRef,
|
6 |
-
useMemo,
|
7 |
-
} from "react";
|
8 |
import { Button } from "./ui/button";
|
9 |
import {
|
10 |
Card,
|
@@ -15,45 +9,35 @@ import {
|
|
15 |
} from "./ui/card";
|
16 |
import { Badge } from "./ui/badge";
|
17 |
import {
|
18 |
-
|
19 |
-
WebCalibrationController,
|
20 |
-
saveCalibrationResults,
|
21 |
type WebCalibrationResults,
|
|
|
|
|
22 |
} from "../../lerobot/web/calibrate";
|
|
|
|
|
|
|
23 |
import { CalibrationModal } from "./CalibrationModal";
|
24 |
-
import type {
|
25 |
|
26 |
interface CalibrationPanelProps {
|
27 |
-
robot:
|
28 |
onFinish: () => void;
|
29 |
}
|
30 |
|
31 |
-
|
32 |
-
|
33 |
-
current: number;
|
34 |
-
min: number;
|
35 |
-
max: number;
|
36 |
-
range: number;
|
37 |
-
}
|
38 |
-
|
39 |
-
/**
|
40 |
-
* Custom hook for calibration that manages the serial port properly
|
41 |
-
* Uses vanilla calibration functions internally, provides React-friendly interface
|
42 |
-
*/
|
43 |
-
function useCalibration(robot: ConnectedRobot) {
|
44 |
-
const [controller, setController] = useState<WebCalibrationController | null>(
|
45 |
-
null
|
46 |
-
);
|
47 |
const [isCalibrating, setIsCalibrating] = useState(false);
|
48 |
-
const [isRecordingRanges, setIsRecordingRanges] = useState(false);
|
49 |
const [calibrationResult, setCalibrationResult] =
|
50 |
useState<WebCalibrationResults | null>(null);
|
51 |
const [status, setStatus] = useState<string>("Ready to calibrate");
|
|
|
|
|
|
|
|
|
|
|
52 |
|
53 |
-
// Motor
|
54 |
-
const [motorData, setMotorData] = useState<MotorCalibrationData[]>([]);
|
55 |
-
|
56 |
-
// Static motor names - use useMemo to prevent recreation on every render
|
57 |
const motorNames = useMemo(
|
58 |
() => [
|
59 |
"shoulder_pan",
|
@@ -66,268 +50,49 @@ function useCalibration(robot: ConnectedRobot) {
|
|
66 |
[]
|
67 |
);
|
68 |
|
69 |
-
// Initialize controller when robot changes
|
70 |
-
const initializeController = useCallback(async () => {
|
71 |
-
if (!robot.port || !robot.robotType) {
|
72 |
-
throw new Error("Invalid robot configuration");
|
73 |
-
}
|
74 |
-
|
75 |
-
const newController = await createCalibrationController(
|
76 |
-
robot.robotType,
|
77 |
-
robot.port
|
78 |
-
);
|
79 |
-
setController(newController);
|
80 |
-
return newController;
|
81 |
-
}, [robot.port, robot.robotType]);
|
82 |
-
|
83 |
-
// Read motor positions using the controller (no concurrent access)
|
84 |
-
const readMotorPositions = useCallback(async (): Promise<number[]> => {
|
85 |
-
if (!controller) {
|
86 |
-
throw new Error("Controller not initialized");
|
87 |
-
}
|
88 |
-
return await controller.readMotorPositions();
|
89 |
-
}, [controller]);
|
90 |
-
|
91 |
-
// Update motor data from positions
|
92 |
-
const updateMotorData = useCallback(
|
93 |
-
(
|
94 |
-
positions: number[],
|
95 |
-
rangeMins?: { [motor: string]: number },
|
96 |
-
rangeMaxes?: { [motor: string]: number }
|
97 |
-
) => {
|
98 |
-
const newData = motorNames.map((name, index) => {
|
99 |
-
const current = positions[index];
|
100 |
-
const min = rangeMins ? rangeMins[name] : current;
|
101 |
-
const max = rangeMaxes ? rangeMaxes[name] : current;
|
102 |
-
|
103 |
-
return {
|
104 |
-
name,
|
105 |
-
current,
|
106 |
-
min,
|
107 |
-
max,
|
108 |
-
range: max - min,
|
109 |
-
};
|
110 |
-
});
|
111 |
-
|
112 |
-
setMotorData(newData);
|
113 |
-
},
|
114 |
-
[motorNames]
|
115 |
-
);
|
116 |
-
|
117 |
// Initialize motor data
|
118 |
const initializeMotorData = useCallback(() => {
|
119 |
-
const initialData =
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
|
125 |
-
|
|
|
|
|
126 |
setMotorData(initialData);
|
127 |
}, [motorNames]);
|
128 |
|
129 |
-
//
|
130 |
-
const
|
|
|
|
|
|
|
|
|
131 |
try {
|
132 |
-
|
133 |
-
|
134 |
|
135 |
-
|
|
|
|
|
|
|
136 |
|
137 |
-
//
|
138 |
-
|
139 |
-
await ctrl.performHomingStep();
|
140 |
|
141 |
-
|
142 |
} catch (error) {
|
143 |
-
|
144 |
-
|
|
|
|
|
145 |
}
|
146 |
-
}, [
|
147 |
-
|
148 |
-
// Start range recording
|
149 |
-
const startRangeRecording = useCallback(
|
150 |
-
async (
|
151 |
-
controllerToUse: WebCalibrationController,
|
152 |
-
stopFunction: () => boolean,
|
153 |
-
onUpdate?: (
|
154 |
-
mins: { [motor: string]: number },
|
155 |
-
maxes: { [motor: string]: number },
|
156 |
-
currentPositions: { [motor: string]: number }
|
157 |
-
) => void
|
158 |
-
) => {
|
159 |
-
if (!controllerToUse) {
|
160 |
-
throw new Error("Controller not provided");
|
161 |
-
}
|
162 |
-
|
163 |
-
setStatus(
|
164 |
-
"📏 Recording joint ranges - move all joints through their full range"
|
165 |
-
);
|
166 |
-
setIsRecordingRanges(true);
|
167 |
-
|
168 |
-
try {
|
169 |
-
await controllerToUse.performRangeRecordingStep(
|
170 |
-
stopFunction,
|
171 |
-
(rangeMins, rangeMaxes, currentPositions) => {
|
172 |
-
setStatus("📏 Recording joint ranges - capturing data...");
|
173 |
-
|
174 |
-
// Update motor data with CURRENT LIVE POSITIONS (not averages!)
|
175 |
-
const currentPositionsArray = motorNames.map(
|
176 |
-
(name) => currentPositions[name]
|
177 |
-
);
|
178 |
-
updateMotorData(currentPositionsArray, rangeMins, rangeMaxes);
|
179 |
-
|
180 |
-
if (onUpdate) {
|
181 |
-
onUpdate(rangeMins, rangeMaxes, currentPositions);
|
182 |
-
}
|
183 |
-
}
|
184 |
-
);
|
185 |
-
} finally {
|
186 |
-
setIsRecordingRanges(false);
|
187 |
-
}
|
188 |
-
},
|
189 |
-
[motorNames, updateMotorData]
|
190 |
-
);
|
191 |
-
|
192 |
-
// Finish calibration
|
193 |
-
const finishCalibration = useCallback(
|
194 |
-
async (
|
195 |
-
controllerToUse?: WebCalibrationController,
|
196 |
-
recordingCount?: number
|
197 |
-
) => {
|
198 |
-
const ctrl = controllerToUse || controller;
|
199 |
-
if (!ctrl) {
|
200 |
-
throw new Error("Controller not initialized");
|
201 |
-
}
|
202 |
-
|
203 |
-
setStatus("💾 Finishing calibration...");
|
204 |
-
const result = await ctrl.finishCalibration();
|
205 |
-
setCalibrationResult(result);
|
206 |
-
|
207 |
-
// Save results using serial number for dashboard detection
|
208 |
-
// Use the same serial number logic as dashboard: prefer main serialNumber, fallback to USB metadata, then "unknown"
|
209 |
-
const serialNumber =
|
210 |
-
robot.serialNumber || robot.usbMetadata?.serialNumber || "unknown";
|
211 |
-
|
212 |
-
console.log("🔍 Debug - Saving calibration with:", {
|
213 |
-
robotType: robot.robotType,
|
214 |
-
robotId: robot.robotId,
|
215 |
-
mainSerialNumber: robot.serialNumber,
|
216 |
-
usbSerialNumber: robot.usbMetadata?.serialNumber,
|
217 |
-
finalSerialNumber: serialNumber,
|
218 |
-
storageKey: `lerobotjs-${serialNumber}`,
|
219 |
-
});
|
220 |
-
|
221 |
-
await saveCalibrationResults(
|
222 |
-
result,
|
223 |
-
robot.robotType!,
|
224 |
-
robot.robotId || `${robot.robotType}_1`,
|
225 |
-
serialNumber,
|
226 |
-
recordingCount || 0
|
227 |
-
);
|
228 |
-
|
229 |
-
// Update final motor data
|
230 |
-
const finalData = motorNames.map((motorName) => {
|
231 |
-
const motorResult = result[motorName];
|
232 |
-
return {
|
233 |
-
name: motorName,
|
234 |
-
current: (motorResult.range_min + motorResult.range_max) / 2,
|
235 |
-
min: motorResult.range_min,
|
236 |
-
max: motorResult.range_max,
|
237 |
-
range: motorResult.range_max - motorResult.range_min,
|
238 |
-
};
|
239 |
-
});
|
240 |
|
241 |
-
|
242 |
-
|
243 |
-
setIsCalibrating(false);
|
244 |
-
|
245 |
-
return result;
|
246 |
-
},
|
247 |
-
[controller, robot.robotType, robot.robotId, motorNames]
|
248 |
-
);
|
249 |
-
|
250 |
-
// Reset states
|
251 |
-
const reset = useCallback(() => {
|
252 |
-
setController(null);
|
253 |
-
setIsCalibrating(false);
|
254 |
-
setIsRecordingRanges(false);
|
255 |
-
setCalibrationResult(null);
|
256 |
-
setStatus("Ready to calibrate");
|
257 |
-
initializeMotorData();
|
258 |
-
}, [initializeMotorData]);
|
259 |
-
|
260 |
-
// Initialize on mount
|
261 |
-
useEffect(() => {
|
262 |
-
initializeMotorData();
|
263 |
-
}, [initializeMotorData]);
|
264 |
-
|
265 |
-
return {
|
266 |
-
// State
|
267 |
-
controller,
|
268 |
-
isCalibrating,
|
269 |
-
isRecordingRanges,
|
270 |
-
calibrationResult,
|
271 |
-
status,
|
272 |
-
motorData,
|
273 |
-
|
274 |
-
// Actions
|
275 |
-
startCalibration,
|
276 |
-
startRangeRecording,
|
277 |
-
finishCalibration,
|
278 |
-
readMotorPositions,
|
279 |
-
reset,
|
280 |
-
|
281 |
-
// Utilities
|
282 |
-
updateMotorData,
|
283 |
-
};
|
284 |
-
}
|
285 |
-
|
286 |
-
export function CalibrationPanel({ robot, onFinish }: CalibrationPanelProps) {
|
287 |
-
const {
|
288 |
-
controller,
|
289 |
-
isCalibrating,
|
290 |
-
isRecordingRanges,
|
291 |
-
calibrationResult,
|
292 |
-
status,
|
293 |
-
motorData,
|
294 |
-
startCalibration,
|
295 |
-
startRangeRecording,
|
296 |
-
finishCalibration,
|
297 |
-
readMotorPositions,
|
298 |
-
reset,
|
299 |
-
updateMotorData,
|
300 |
-
} = useCalibration(robot);
|
301 |
-
|
302 |
-
// Modal state
|
303 |
-
const [modalOpen, setModalOpen] = useState(false);
|
304 |
-
|
305 |
-
// Recording state
|
306 |
-
const [stopRecordingFunction, setStopRecordingFunction] = useState<
|
307 |
-
(() => void) | null
|
308 |
-
>(null);
|
309 |
-
|
310 |
-
// Motor names matching Python lerobot exactly (NOT Node CLI)
|
311 |
-
const motorNames = [
|
312 |
-
"shoulder_pan",
|
313 |
-
"shoulder_lift",
|
314 |
-
"elbow_flex",
|
315 |
-
"wrist_flex",
|
316 |
-
"wrist_roll",
|
317 |
-
"gripper",
|
318 |
-
];
|
319 |
-
|
320 |
-
// Motor IDs for SO-100 (STS3215 servos)
|
321 |
-
const motorIds = [1, 2, 3, 4, 5, 6];
|
322 |
-
|
323 |
-
// Keep track of last known good positions to avoid glitches
|
324 |
-
const lastKnownPositions = useRef<number[]>([
|
325 |
-
2047, 2047, 2047, 2047, 2047, 2047,
|
326 |
-
]);
|
327 |
-
|
328 |
-
// NO concurrent motor reading - let the calibration hook handle all serial operations
|
329 |
-
|
330 |
-
const handleContinueCalibration = async () => {
|
331 |
setModalOpen(false);
|
332 |
|
333 |
if (!robot.port || !robot.robotType) {
|
@@ -335,76 +100,119 @@ export function CalibrationPanel({ robot, onFinish }: CalibrationPanelProps) {
|
|
335 |
}
|
336 |
|
337 |
try {
|
338 |
-
|
339 |
-
|
340 |
-
|
341 |
-
|
342 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
343 |
|
344 |
-
|
345 |
-
const stopRecording = () => {
|
346 |
-
shouldStopRecording = true;
|
347 |
-
};
|
348 |
-
setStopRecordingFunction(() => stopRecording);
|
349 |
|
350 |
-
// Add Enter key listener
|
351 |
const handleKeyPress = (event: KeyboardEvent) => {
|
352 |
if (event.key === "Enter") {
|
353 |
-
|
354 |
}
|
355 |
};
|
356 |
-
|
357 |
document.addEventListener("keydown", handleKeyPress);
|
358 |
|
359 |
try {
|
360 |
-
|
361 |
-
|
362 |
-
|
363 |
-
|
364 |
-
|
365 |
-
|
366 |
-
|
367 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
368 |
);
|
369 |
} finally {
|
370 |
document.removeEventListener("keydown", handleKeyPress);
|
371 |
-
|
|
|
372 |
}
|
373 |
-
|
374 |
-
// Step 3: Finish calibration with recording count
|
375 |
-
await finishCalibration(ctrl, recordingCount);
|
376 |
} catch (error) {
|
377 |
console.error("❌ Calibration failed:", error);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
378 |
}
|
379 |
-
};
|
380 |
|
381 |
-
//
|
382 |
-
const
|
383 |
-
|
384 |
-
|
385 |
-
return calibrationResult;
|
386 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
387 |
|
388 |
-
|
389 |
-
|
390 |
-
|
391 |
-
const motor = motorData[index];
|
392 |
-
calibrationData[motorName] = {
|
393 |
-
homing_offset: motor.current - 2047, // Center offset for STS3215 (4095/2)
|
394 |
-
drive_mode: 0, // Python lerobot SO-100 uses drive_mode 0
|
395 |
-
start_pos: motor.min,
|
396 |
-
end_pos: motor.max,
|
397 |
-
calib_mode: "middle", // Python lerobot SO-100 standard
|
398 |
};
|
399 |
-
});
|
400 |
|
401 |
-
|
|
|
|
|
|
|
402 |
};
|
403 |
|
404 |
-
//
|
405 |
-
const downloadConfigJSON = () => {
|
406 |
-
|
407 |
-
|
|
|
408 |
const blob = new Blob([jsonString], { type: "application/json" });
|
409 |
const url = URL.createObjectURL(blob);
|
410 |
|
@@ -415,7 +223,7 @@ export function CalibrationPanel({ robot, onFinish }: CalibrationPanelProps) {
|
|
415 |
link.click();
|
416 |
document.body.removeChild(link);
|
417 |
URL.revokeObjectURL(url);
|
418 |
-
};
|
419 |
|
420 |
return (
|
421 |
<div className="space-y-4">
|
@@ -455,27 +263,29 @@ export function CalibrationPanel({ robot, onFinish }: CalibrationPanelProps) {
|
|
455 |
<p className="text-sm text-blue-800">{status}</p>
|
456 |
{isCalibrating && (
|
457 |
<p className="text-xs text-blue-600 mt-1">
|
458 |
-
Move joints through full range | Press "Finish Recording"
|
459 |
-
done
|
460 |
</p>
|
461 |
)}
|
462 |
</div>
|
463 |
|
464 |
<div className="flex gap-2">
|
465 |
{!isCalibrating && !calibrationResult && (
|
466 |
-
<Button
|
467 |
-
|
468 |
-
|
469 |
-
|
470 |
-
|
471 |
-
|
472 |
-
|
473 |
-
|
|
|
|
|
474 |
</Button>
|
475 |
)}
|
476 |
|
477 |
-
{
|
478 |
-
<Button onClick={
|
479 |
Finish Recording
|
480 |
</Button>
|
481 |
)}
|
@@ -507,7 +317,7 @@ export function CalibrationPanel({ robot, onFinish }: CalibrationPanelProps) {
|
|
507 |
<CardContent>
|
508 |
<div className="space-y-3">
|
509 |
<pre className="bg-gray-100 p-4 rounded-lg text-sm overflow-x-auto border">
|
510 |
-
<code>{JSON.stringify(
|
511 |
</pre>
|
512 |
<div className="flex gap-2">
|
513 |
<Button onClick={downloadConfigJSON} variant="outline">
|
@@ -516,7 +326,7 @@ export function CalibrationPanel({ robot, onFinish }: CalibrationPanelProps) {
|
|
516 |
<Button
|
517 |
onClick={() => {
|
518 |
navigator.clipboard.writeText(
|
519 |
-
JSON.stringify(
|
520 |
);
|
521 |
}}
|
522 |
variant="outline"
|
@@ -529,12 +339,12 @@ export function CalibrationPanel({ robot, onFinish }: CalibrationPanelProps) {
|
|
529 |
</Card>
|
530 |
)}
|
531 |
|
532 |
-
{/* Live Position Recording Table
|
533 |
<Card>
|
534 |
<CardHeader>
|
535 |
<CardTitle className="text-lg">Live Position Recording</CardTitle>
|
536 |
<CardDescription>
|
537 |
-
Real-time motor position feedback
|
538 |
</CardDescription>
|
539 |
</CardHeader>
|
540 |
<CardContent>
|
@@ -560,28 +370,39 @@ export function CalibrationPanel({ robot, onFinish }: CalibrationPanelProps) {
|
|
560 |
</tr>
|
561 |
</thead>
|
562 |
<tbody className="divide-y divide-gray-200">
|
563 |
-
{
|
564 |
-
|
565 |
-
|
566 |
-
|
567 |
-
|
568 |
-
|
569 |
-
|
570 |
-
|
571 |
-
|
572 |
-
<
|
573 |
-
|
574 |
-
|
575 |
-
|
576 |
-
|
577 |
-
|
578 |
-
|
579 |
-
>
|
580 |
-
|
581 |
-
</
|
582 |
-
|
583 |
-
|
584 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
585 |
</tbody>
|
586 |
</table>
|
587 |
</div>
|
|
|
1 |
+
import React, { useState, useCallback, useMemo } from "react";
|
|
|
|
|
|
|
|
|
|
|
|
|
2 |
import { Button } from "./ui/button";
|
3 |
import {
|
4 |
Card,
|
|
|
9 |
} from "./ui/card";
|
10 |
import { Badge } from "./ui/badge";
|
11 |
import {
|
12 |
+
calibrate,
|
|
|
|
|
13 |
type WebCalibrationResults,
|
14 |
+
type LiveCalibrationData,
|
15 |
+
type CalibrationProcess,
|
16 |
} from "../../lerobot/web/calibrate";
|
17 |
+
import { releaseMotors } from "../../lerobot/web/utils/motor-communication.js";
|
18 |
+
import { WebSerialPortWrapper } from "../../lerobot/web/utils/serial-port-wrapper.js";
|
19 |
+
import { createSO100Config } from "../../lerobot/web/robots/so100_config.js";
|
20 |
import { CalibrationModal } from "./CalibrationModal";
|
21 |
+
import type { RobotConnection } from "../../lerobot/web/find_port.js";
|
22 |
|
23 |
interface CalibrationPanelProps {
|
24 |
+
robot: RobotConnection;
|
25 |
onFinish: () => void;
|
26 |
}
|
27 |
|
28 |
+
export function CalibrationPanel({ robot, onFinish }: CalibrationPanelProps) {
|
29 |
+
// Simple state management
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
30 |
const [isCalibrating, setIsCalibrating] = useState(false);
|
|
|
31 |
const [calibrationResult, setCalibrationResult] =
|
32 |
useState<WebCalibrationResults | null>(null);
|
33 |
const [status, setStatus] = useState<string>("Ready to calibrate");
|
34 |
+
const [modalOpen, setModalOpen] = useState(false);
|
35 |
+
const [calibrationProcess, setCalibrationProcess] =
|
36 |
+
useState<CalibrationProcess | null>(null);
|
37 |
+
const [motorData, setMotorData] = useState<LiveCalibrationData>({});
|
38 |
+
const [isPreparing, setIsPreparing] = useState(false);
|
39 |
|
40 |
+
// Motor names for display
|
|
|
|
|
|
|
41 |
const motorNames = useMemo(
|
42 |
() => [
|
43 |
"shoulder_pan",
|
|
|
50 |
[]
|
51 |
);
|
52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
53 |
// Initialize motor data
|
54 |
const initializeMotorData = useCallback(() => {
|
55 |
+
const initialData: LiveCalibrationData = {};
|
56 |
+
motorNames.forEach((name) => {
|
57 |
+
initialData[name] = {
|
58 |
+
current: 2047,
|
59 |
+
min: 2047,
|
60 |
+
max: 2047,
|
61 |
+
range: 0,
|
62 |
+
};
|
63 |
+
});
|
64 |
setMotorData(initialData);
|
65 |
}, [motorNames]);
|
66 |
|
67 |
+
// Release motor torque for better UX - allows immediate joint movement
|
68 |
+
const releaseMotorTorque = useCallback(async () => {
|
69 |
+
if (!robot.port || !robot.robotType) {
|
70 |
+
return;
|
71 |
+
}
|
72 |
+
|
73 |
try {
|
74 |
+
setIsPreparing(true);
|
75 |
+
setStatus("🔓 Releasing motor torque - joints can now be moved freely");
|
76 |
|
77 |
+
// Create port wrapper and config to get motor IDs
|
78 |
+
const port = new WebSerialPortWrapper(robot.port);
|
79 |
+
await port.initialize();
|
80 |
+
const config = createSO100Config(robot.robotType);
|
81 |
|
82 |
+
// Release motors so they can be moved freely by hand
|
83 |
+
await releaseMotors(port, config.motorIds);
|
|
|
84 |
|
85 |
+
setStatus("✅ Joints are now free to move - set your homing position");
|
86 |
} catch (error) {
|
87 |
+
console.warn("Failed to release motor torque:", error);
|
88 |
+
setStatus("⚠️ Could not release motor torque - try moving joints gently");
|
89 |
+
} finally {
|
90 |
+
setIsPreparing(false);
|
91 |
}
|
92 |
+
}, [robot]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
93 |
|
94 |
+
// Start calibration using new API
|
95 |
+
const handleContinueCalibration = useCallback(async () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
96 |
setModalOpen(false);
|
97 |
|
98 |
if (!robot.port || !robot.robotType) {
|
|
|
100 |
}
|
101 |
|
102 |
try {
|
103 |
+
setStatus("🤖 Starting calibration process...");
|
104 |
+
setIsCalibrating(true);
|
105 |
+
initializeMotorData();
|
106 |
+
|
107 |
+
// Use the new unified calibrate API - pass the whole robot connection
|
108 |
+
const robotConnection = {
|
109 |
+
port: robot.port,
|
110 |
+
robotType: robot.robotType!,
|
111 |
+
robotId: robot.robotId || `${robot.robotType}_1`,
|
112 |
+
serialNumber: robot.serialNumber || `unknown_${Date.now()}`,
|
113 |
+
connected: robot.isConnected,
|
114 |
+
} as any; // Type assertion to work around SerialPort type differences
|
115 |
+
|
116 |
+
const process = await calibrate(robotConnection, {
|
117 |
+
onLiveUpdate: (data) => {
|
118 |
+
setMotorData(data);
|
119 |
+
setStatus(
|
120 |
+
"📏 Recording joint ranges - move all joints through their full range"
|
121 |
+
);
|
122 |
+
},
|
123 |
+
onProgress: (message) => {
|
124 |
+
setStatus(message);
|
125 |
+
},
|
126 |
+
});
|
127 |
|
128 |
+
setCalibrationProcess(process);
|
|
|
|
|
|
|
|
|
129 |
|
130 |
+
// Add Enter key listener for stopping (matching Node.js UX)
|
131 |
const handleKeyPress = (event: KeyboardEvent) => {
|
132 |
if (event.key === "Enter") {
|
133 |
+
process.stop();
|
134 |
}
|
135 |
};
|
|
|
136 |
document.addEventListener("keydown", handleKeyPress);
|
137 |
|
138 |
try {
|
139 |
+
// Wait for calibration to complete
|
140 |
+
const result = await process.result;
|
141 |
+
setCalibrationResult(result);
|
142 |
+
|
143 |
+
// App-level concern: Save results to storage
|
144 |
+
const serialNumber =
|
145 |
+
robot.serialNumber || robot.usbMetadata?.serialNumber || "unknown";
|
146 |
+
await saveCalibrationResults(
|
147 |
+
result,
|
148 |
+
robot.robotType,
|
149 |
+
robot.robotId || `${robot.robotType}_1`,
|
150 |
+
serialNumber
|
151 |
+
);
|
152 |
+
|
153 |
+
setStatus(
|
154 |
+
"✅ Calibration completed successfully! Configuration saved."
|
155 |
);
|
156 |
} finally {
|
157 |
document.removeEventListener("keydown", handleKeyPress);
|
158 |
+
setCalibrationProcess(null);
|
159 |
+
setIsCalibrating(false);
|
160 |
}
|
|
|
|
|
|
|
161 |
} catch (error) {
|
162 |
console.error("❌ Calibration failed:", error);
|
163 |
+
setStatus(
|
164 |
+
`❌ Calibration failed: ${
|
165 |
+
error instanceof Error ? error.message : error
|
166 |
+
}`
|
167 |
+
);
|
168 |
+
setIsCalibrating(false);
|
169 |
+
setCalibrationProcess(null);
|
170 |
}
|
171 |
+
}, [robot, initializeMotorData]);
|
172 |
|
173 |
+
// Stop calibration recording
|
174 |
+
const handleStopRecording = useCallback(() => {
|
175 |
+
if (calibrationProcess) {
|
176 |
+
calibrationProcess.stop();
|
|
|
177 |
}
|
178 |
+
}, [calibrationProcess]);
|
179 |
+
|
180 |
+
// App-level concern: Save calibration results
|
181 |
+
const saveCalibrationResults = async (
|
182 |
+
results: WebCalibrationResults,
|
183 |
+
robotType: string,
|
184 |
+
robotId: string,
|
185 |
+
serialNumber: string
|
186 |
+
) => {
|
187 |
+
try {
|
188 |
+
// Save to unified storage (app-level functionality)
|
189 |
+
const { saveCalibrationData } = await import("../lib/unified-storage.js");
|
190 |
+
|
191 |
+
const fullCalibrationData = {
|
192 |
+
...results,
|
193 |
+
device_type: robotType,
|
194 |
+
device_id: robotId,
|
195 |
+
calibrated_at: new Date().toISOString(),
|
196 |
+
platform: "web",
|
197 |
+
api: "Web Serial API",
|
198 |
+
};
|
199 |
|
200 |
+
const metadata = {
|
201 |
+
timestamp: new Date().toISOString(),
|
202 |
+
readCount: Object.keys(motorData).length > 0 ? 100 : 0, // Estimate
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
203 |
};
|
|
|
204 |
|
205 |
+
saveCalibrationData(serialNumber, fullCalibrationData, metadata);
|
206 |
+
} catch (error) {
|
207 |
+
console.warn("Failed to save calibration results:", error);
|
208 |
+
}
|
209 |
};
|
210 |
|
211 |
+
// App-level concern: JSON export functionality
|
212 |
+
const downloadConfigJSON = useCallback(() => {
|
213 |
+
if (!calibrationResult) return;
|
214 |
+
|
215 |
+
const jsonString = JSON.stringify(calibrationResult, null, 2);
|
216 |
const blob = new Blob([jsonString], { type: "application/json" });
|
217 |
const url = URL.createObjectURL(blob);
|
218 |
|
|
|
223 |
link.click();
|
224 |
document.body.removeChild(link);
|
225 |
URL.revokeObjectURL(url);
|
226 |
+
}, [calibrationResult, robot.robotId, robot.robotType]);
|
227 |
|
228 |
return (
|
229 |
<div className="space-y-4">
|
|
|
263 |
<p className="text-sm text-blue-800">{status}</p>
|
264 |
{isCalibrating && (
|
265 |
<p className="text-xs text-blue-600 mt-1">
|
266 |
+
Move joints through full range | Press "Finish Recording" or
|
267 |
+
Enter key when done
|
268 |
</p>
|
269 |
)}
|
270 |
</div>
|
271 |
|
272 |
<div className="flex gap-2">
|
273 |
{!isCalibrating && !calibrationResult && (
|
274 |
+
<Button
|
275 |
+
onClick={async () => {
|
276 |
+
// Release motor torque FIRST - so user can move joints immediately
|
277 |
+
await releaseMotorTorque();
|
278 |
+
// THEN open modal - user can now follow instructions right away
|
279 |
+
setModalOpen(true);
|
280 |
+
}}
|
281 |
+
disabled={isPreparing}
|
282 |
+
>
|
283 |
+
{isPreparing ? "Preparing..." : "Start Calibration"}
|
284 |
</Button>
|
285 |
)}
|
286 |
|
287 |
+
{isCalibrating && calibrationProcess && (
|
288 |
+
<Button onClick={handleStopRecording} variant="default">
|
289 |
Finish Recording
|
290 |
</Button>
|
291 |
)}
|
|
|
317 |
<CardContent>
|
318 |
<div className="space-y-3">
|
319 |
<pre className="bg-gray-100 p-4 rounded-lg text-sm overflow-x-auto border">
|
320 |
+
<code>{JSON.stringify(calibrationResult, null, 2)}</code>
|
321 |
</pre>
|
322 |
<div className="flex gap-2">
|
323 |
<Button onClick={downloadConfigJSON} variant="outline">
|
|
|
326 |
<Button
|
327 |
onClick={() => {
|
328 |
navigator.clipboard.writeText(
|
329 |
+
JSON.stringify(calibrationResult, null, 2)
|
330 |
);
|
331 |
}}
|
332 |
variant="outline"
|
|
|
339 |
</Card>
|
340 |
)}
|
341 |
|
342 |
+
{/* Live Position Recording Table */}
|
343 |
<Card>
|
344 |
<CardHeader>
|
345 |
<CardTitle className="text-lg">Live Position Recording</CardTitle>
|
346 |
<CardDescription>
|
347 |
+
Real-time motor position feedback during calibration
|
348 |
</CardDescription>
|
349 |
</CardHeader>
|
350 |
<CardContent>
|
|
|
370 |
</tr>
|
371 |
</thead>
|
372 |
<tbody className="divide-y divide-gray-200">
|
373 |
+
{motorNames.map((motorName) => {
|
374 |
+
const motor = motorData[motorName] || {
|
375 |
+
current: 2047,
|
376 |
+
min: 2047,
|
377 |
+
max: 2047,
|
378 |
+
range: 0,
|
379 |
+
};
|
380 |
+
|
381 |
+
return (
|
382 |
+
<tr key={motorName} className="hover:bg-gray-50">
|
383 |
+
<td className="px-4 py-2 font-medium flex items-center gap-2">
|
384 |
+
{motorName}
|
385 |
+
{motor.range > 100 && (
|
386 |
+
<span className="text-green-600 text-xs">✓</span>
|
387 |
+
)}
|
388 |
+
</td>
|
389 |
+
<td className="px-4 py-2 text-right">{motor.current}</td>
|
390 |
+
<td className="px-4 py-2 text-right">{motor.min}</td>
|
391 |
+
<td className="px-4 py-2 text-right">{motor.max}</td>
|
392 |
+
<td className="px-4 py-2 text-right font-medium">
|
393 |
+
<span
|
394 |
+
className={
|
395 |
+
motor.range > 100
|
396 |
+
? "text-green-600"
|
397 |
+
: "text-gray-500"
|
398 |
+
}
|
399 |
+
>
|
400 |
+
{motor.range}
|
401 |
+
</span>
|
402 |
+
</td>
|
403 |
+
</tr>
|
404 |
+
);
|
405 |
+
})}
|
406 |
</tbody>
|
407 |
</table>
|
408 |
</div>
|
src/demo/components/PortManager.tsx
CHANGED
@@ -18,17 +18,31 @@ import {
|
|
18 |
DialogTitle,
|
19 |
} from "./ui/dialog";
|
20 |
import { isWebSerialSupported } from "../../lerobot/web/calibrate";
|
21 |
-
import type {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
22 |
|
23 |
interface PortManagerProps {
|
24 |
-
connectedRobots:
|
25 |
-
onConnectedRobotsChange: (robots:
|
26 |
onCalibrate?: (
|
27 |
port: SerialPort,
|
28 |
robotType: "so100_follower" | "so100_leader",
|
29 |
robotId: string
|
30 |
) => void;
|
31 |
-
onTeleoperate?: (robot:
|
32 |
}
|
33 |
|
34 |
export function PortManager({
|
@@ -62,7 +76,7 @@ export function PortManager({
|
|
62 |
const loadSavedPorts = async () => {
|
63 |
try {
|
64 |
const existingPorts = await navigator.serial.getPorts();
|
65 |
-
const restoredPorts:
|
66 |
|
67 |
for (const port of existingPorts) {
|
68 |
// Get USB device metadata to determine serial number
|
@@ -297,7 +311,7 @@ export function PortManager({
|
|
297 |
|
298 |
if (existingIndex === -1) {
|
299 |
// New robot - add to list
|
300 |
-
const newRobot:
|
301 |
port,
|
302 |
name: portName,
|
303 |
isConnected: true,
|
@@ -489,47 +503,35 @@ export function PortManager({
|
|
489 |
setFindPortsLog([]);
|
490 |
setError(null);
|
491 |
|
492 |
-
//
|
493 |
-
const
|
494 |
-
setFindPortsLog((prev) => [
|
495 |
-
...prev,
|
496 |
-
`Found ${initialPorts.length} existing paired port(s)`,
|
497 |
-
]);
|
498 |
-
|
499 |
-
// Ask user to disconnect
|
500 |
-
setFindPortsLog((prev) => [
|
501 |
-
...prev,
|
502 |
-
"Please disconnect the USB cable from your robot and click OK",
|
503 |
-
]);
|
504 |
-
|
505 |
-
// Simple implementation - just show the instruction
|
506 |
-
// In a real implementation, we'd monitor port changes
|
507 |
-
const confirmed = confirm(
|
508 |
-
"Disconnect the USB cable from your robot and click OK when done"
|
509 |
-
);
|
510 |
|
511 |
-
|
512 |
-
|
|
|
|
|
|
|
513 |
|
514 |
-
|
515 |
-
|
516 |
-
await port.open({ baudRate: 1000000 });
|
517 |
|
518 |
-
|
519 |
-
|
520 |
|
521 |
-
|
522 |
-
|
523 |
-
|
524 |
-
|
525 |
-
|
526 |
-
|
527 |
-
|
528 |
-
|
529 |
-
|
530 |
-
|
531 |
-
|
532 |
-
|
|
|
|
|
533 |
}
|
534 |
} catch (error) {
|
535 |
if (
|
@@ -574,7 +576,7 @@ export function PortManager({
|
|
574 |
}
|
575 |
};
|
576 |
|
577 |
-
const handleCalibrate = async (port:
|
578 |
if (!port.robotType || !port.robotId) {
|
579 |
setError("Please set robot type and ID before calibrating");
|
580 |
return;
|
@@ -738,7 +740,7 @@ export function PortManager({
|
|
738 |
}
|
739 |
|
740 |
interface PortCardProps {
|
741 |
-
portInfo:
|
742 |
onDisconnect: () => void;
|
743 |
onUpdateInfo: (
|
744 |
robotType: "so100_follower" | "so100_leader",
|
|
|
18 |
DialogTitle,
|
19 |
} from "./ui/dialog";
|
20 |
import { isWebSerialSupported } from "../../lerobot/web/calibrate";
|
21 |
+
import type { RobotConnection } from "../../lerobot/web/find_port.js";
|
22 |
+
|
23 |
+
/**
|
24 |
+
* Type definitions for WebSerial API (missing from TypeScript)
|
25 |
+
*/
|
26 |
+
interface SerialPortInfo {
|
27 |
+
usbVendorId?: number;
|
28 |
+
usbProductId?: number;
|
29 |
+
}
|
30 |
+
|
31 |
+
declare global {
|
32 |
+
interface SerialPort {
|
33 |
+
getInfo(): SerialPortInfo;
|
34 |
+
}
|
35 |
+
}
|
36 |
|
37 |
interface PortManagerProps {
|
38 |
+
connectedRobots: RobotConnection[];
|
39 |
+
onConnectedRobotsChange: (robots: RobotConnection[]) => void;
|
40 |
onCalibrate?: (
|
41 |
port: SerialPort,
|
42 |
robotType: "so100_follower" | "so100_leader",
|
43 |
robotId: string
|
44 |
) => void;
|
45 |
+
onTeleoperate?: (robot: RobotConnection) => void;
|
46 |
}
|
47 |
|
48 |
export function PortManager({
|
|
|
76 |
const loadSavedPorts = async () => {
|
77 |
try {
|
78 |
const existingPorts = await navigator.serial.getPorts();
|
79 |
+
const restoredPorts: RobotConnection[] = [];
|
80 |
|
81 |
for (const port of existingPorts) {
|
82 |
// Get USB device metadata to determine serial number
|
|
|
311 |
|
312 |
if (existingIndex === -1) {
|
313 |
// New robot - add to list
|
314 |
+
const newRobot: RobotConnection = {
|
315 |
port,
|
316 |
name: portName,
|
317 |
isConnected: true,
|
|
|
503 |
setFindPortsLog([]);
|
504 |
setError(null);
|
505 |
|
506 |
+
// Use the new findPort API from standard library
|
507 |
+
const { findPort } = await import("../../lerobot/web/find_port.js");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
508 |
|
509 |
+
const findPortProcess = await findPort({
|
510 |
+
onMessage: (message) => {
|
511 |
+
setFindPortsLog((prev) => [...prev, message]);
|
512 |
+
},
|
513 |
+
});
|
514 |
|
515 |
+
const robotConnections = (await findPortProcess.result) as any; // RobotConnection[] from findPort
|
516 |
+
const robotConnection = robotConnections[0]; // Get first robot from array
|
|
|
517 |
|
518 |
+
const portName = getPortDisplayName(robotConnection.port);
|
519 |
+
setFindPortsLog((prev) => [...prev, `✅ Port ready: ${portName}`]);
|
520 |
|
521 |
+
// Add to connected ports if not already there
|
522 |
+
const existingIndex = connectedRobots.findIndex(
|
523 |
+
(p) => p.name === portName
|
524 |
+
);
|
525 |
+
if (existingIndex === -1) {
|
526 |
+
const newPort: RobotConnection = {
|
527 |
+
port: robotConnection.port,
|
528 |
+
name: portName,
|
529 |
+
isConnected: true,
|
530 |
+
robotType: robotConnection.robotType,
|
531 |
+
robotId: robotConnection.robotId,
|
532 |
+
serialNumber: robotConnection.serialNumber,
|
533 |
+
};
|
534 |
+
onConnectedRobotsChange([...connectedRobots, newPort]);
|
535 |
}
|
536 |
} catch (error) {
|
537 |
if (
|
|
|
576 |
}
|
577 |
};
|
578 |
|
579 |
+
const handleCalibrate = async (port: RobotConnection) => {
|
580 |
if (!port.robotType || !port.robotId) {
|
581 |
setError("Please set robot type and ID before calibrating");
|
582 |
return;
|
|
|
740 |
}
|
741 |
|
742 |
interface PortCardProps {
|
743 |
+
portInfo: RobotConnection;
|
744 |
onDisconnect: () => void;
|
745 |
onUpdateInfo: (
|
746 |
robotType: "so100_follower" | "so100_leader",
|
src/demo/components/TeleoperationPanel.tsx
CHANGED
@@ -5,11 +5,11 @@ import { Badge } from "./ui/badge";
|
|
5 |
import { Alert, AlertDescription } from "./ui/alert";
|
6 |
import { Progress } from "./ui/progress";
|
7 |
import { useTeleoperation } from "../hooks/useTeleoperation";
|
8 |
-
import type {
|
9 |
import { KEYBOARD_CONTROLS } from "../../lerobot/web/teleoperate";
|
10 |
|
11 |
interface TeleoperationPanelProps {
|
12 |
-
robot:
|
13 |
onClose: () => void;
|
14 |
}
|
15 |
|
@@ -27,9 +27,9 @@ export function TeleoperationPanel({
|
|
27 |
error,
|
28 |
start,
|
29 |
stop,
|
30 |
-
goToHome,
|
31 |
simulateKeyPress,
|
32 |
simulateKeyRelease,
|
|
|
33 |
} = useTeleoperation({
|
34 |
robot,
|
35 |
enabled,
|
@@ -153,7 +153,7 @@ export function TeleoperationPanel({
|
|
153 |
</div>
|
154 |
</div>
|
155 |
|
156 |
-
{/* Q/E and
|
157 |
<div className="flex justify-center gap-2">
|
158 |
<div className="text-center">
|
159 |
<h4 className="text-xs font-semibold mb-2 text-gray-600">Roll</h4>
|
@@ -170,9 +170,14 @@ export function TeleoperationPanel({
|
|
170 |
<h4 className="text-xs font-semibold mb-2 text-gray-600">
|
171 |
Gripper
|
172 |
</h4>
|
173 |
-
<
|
174 |
-
|
175 |
-
|
|
|
|
|
|
|
|
|
|
|
176 |
</div>
|
177 |
</div>
|
178 |
|
@@ -261,15 +266,6 @@ export function TeleoperationPanel({
|
|
261 |
▶️ Start Teleoperation
|
262 |
</Button>
|
263 |
)}
|
264 |
-
|
265 |
-
<Button
|
266 |
-
onClick={goToHome}
|
267 |
-
variant="outline"
|
268 |
-
disabled={!isConnected}
|
269 |
-
className="w-full"
|
270 |
-
>
|
271 |
-
🏠 Go to Home
|
272 |
-
</Button>
|
273 |
</div>
|
274 |
</CardContent>
|
275 |
</Card>
|
@@ -290,11 +286,7 @@ export function TeleoperationPanel({
|
|
290 |
<CardTitle>Motor Positions</CardTitle>
|
291 |
</CardHeader>
|
292 |
<CardContent className="space-y-3">
|
293 |
-
{motorConfigs.map((motor) => {
|
294 |
-
const range = motor.maxPosition - motor.minPosition;
|
295 |
-
const position = motor.currentPosition - motor.minPosition;
|
296 |
-
const percentage = range > 0 ? (position / range) * 100 : 0;
|
297 |
-
|
298 |
return (
|
299 |
<div key={motor.name} className="space-y-1">
|
300 |
<div className="flex justify-between items-center">
|
@@ -305,7 +297,41 @@ export function TeleoperationPanel({
|
|
305 |
{motor.currentPosition}
|
306 |
</span>
|
307 |
</div>
|
308 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
309 |
<div className="flex justify-between text-xs text-gray-400">
|
310 |
<span>{motor.minPosition}</span>
|
311 |
<span>{motor.maxPosition}</span>
|
@@ -342,7 +368,8 @@ export function TeleoperationPanel({
|
|
342 |
<h4 className="font-semibold mb-2">Other Keys</h4>
|
343 |
<ul className="space-y-1 text-gray-600">
|
344 |
<li>Q E Wrist roll</li>
|
345 |
-
<li>
|
|
|
346 |
</ul>
|
347 |
</div>
|
348 |
<div>
|
|
|
5 |
import { Alert, AlertDescription } from "./ui/alert";
|
6 |
import { Progress } from "./ui/progress";
|
7 |
import { useTeleoperation } from "../hooks/useTeleoperation";
|
8 |
+
import type { RobotConnection } from "../../lerobot/web/find_port.js";
|
9 |
import { KEYBOARD_CONTROLS } from "../../lerobot/web/teleoperate";
|
10 |
|
11 |
interface TeleoperationPanelProps {
|
12 |
+
robot: RobotConnection;
|
13 |
onClose: () => void;
|
14 |
}
|
15 |
|
|
|
27 |
error,
|
28 |
start,
|
29 |
stop,
|
|
|
30 |
simulateKeyPress,
|
31 |
simulateKeyRelease,
|
32 |
+
moveMotorToPosition,
|
33 |
} = useTeleoperation({
|
34 |
robot,
|
35 |
enabled,
|
|
|
153 |
</div>
|
154 |
</div>
|
155 |
|
156 |
+
{/* Q/E and Gripper */}
|
157 |
<div className="flex justify-center gap-2">
|
158 |
<div className="text-center">
|
159 |
<h4 className="text-xs font-semibold mb-2 text-gray-600">Roll</h4>
|
|
|
170 |
<h4 className="text-xs font-semibold mb-2 text-gray-600">
|
171 |
Gripper
|
172 |
</h4>
|
173 |
+
<div className="flex gap-1">
|
174 |
+
<KeyButton keyCode="o" size="sm">
|
175 |
+
O
|
176 |
+
</KeyButton>
|
177 |
+
<KeyButton keyCode="c" size="sm">
|
178 |
+
C
|
179 |
+
</KeyButton>
|
180 |
+
</div>
|
181 |
</div>
|
182 |
</div>
|
183 |
|
|
|
266 |
▶️ Start Teleoperation
|
267 |
</Button>
|
268 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
269 |
</div>
|
270 |
</CardContent>
|
271 |
</Card>
|
|
|
286 |
<CardTitle>Motor Positions</CardTitle>
|
287 |
</CardHeader>
|
288 |
<CardContent className="space-y-3">
|
289 |
+
{motorConfigs.map((motor, index) => {
|
|
|
|
|
|
|
|
|
290 |
return (
|
291 |
<div key={motor.name} className="space-y-1">
|
292 |
<div className="flex justify-between items-center">
|
|
|
297 |
{motor.currentPosition}
|
298 |
</span>
|
299 |
</div>
|
300 |
+
<input
|
301 |
+
type="range"
|
302 |
+
min={motor.minPosition}
|
303 |
+
max={motor.maxPosition}
|
304 |
+
value={motor.currentPosition}
|
305 |
+
disabled={!isActive}
|
306 |
+
className={`w-full h-2 rounded-lg appearance-none cursor-pointer bg-gray-200 slider-thumb ${
|
307 |
+
!isActive ? "opacity-50 cursor-not-allowed" : ""
|
308 |
+
}`}
|
309 |
+
style={{
|
310 |
+
background: isActive
|
311 |
+
? `linear-gradient(to right, #3b82f6 0%, #3b82f6 ${
|
312 |
+
((motor.currentPosition - motor.minPosition) /
|
313 |
+
(motor.maxPosition - motor.minPosition)) *
|
314 |
+
100
|
315 |
+
}%, #e5e7eb ${
|
316 |
+
((motor.currentPosition - motor.minPosition) /
|
317 |
+
(motor.maxPosition - motor.minPosition)) *
|
318 |
+
100
|
319 |
+
}%, #e5e7eb 100%)`
|
320 |
+
: "#e5e7eb",
|
321 |
+
}}
|
322 |
+
onChange={async (e) => {
|
323 |
+
if (!isActive) return;
|
324 |
+
const newPosition = parseInt(e.target.value);
|
325 |
+
try {
|
326 |
+
await moveMotorToPosition(index, newPosition);
|
327 |
+
} catch (error) {
|
328 |
+
console.warn(
|
329 |
+
"Failed to move motor via slider:",
|
330 |
+
error
|
331 |
+
);
|
332 |
+
}
|
333 |
+
}}
|
334 |
+
/>
|
335 |
<div className="flex justify-between text-xs text-gray-400">
|
336 |
<span>{motor.minPosition}</span>
|
337 |
<span>{motor.maxPosition}</span>
|
|
|
368 |
<h4 className="font-semibold mb-2">Other Keys</h4>
|
369 |
<ul className="space-y-1 text-gray-600">
|
370 |
<li>Q E Wrist roll</li>
|
371 |
+
<li>O Open gripper</li>
|
372 |
+
<li>C Close gripper</li>
|
373 |
</ul>
|
374 |
</div>
|
375 |
<div>
|
src/demo/hooks/useTeleoperation.ts
CHANGED
@@ -1,7 +1,7 @@
|
|
1 |
import { useState, useEffect, useCallback, useRef } from "react";
|
2 |
import { useRobotConnection } from "./useRobotConnection";
|
3 |
import { getUnifiedRobotData } from "../lib/unified-storage";
|
4 |
-
import type {
|
5 |
|
6 |
export interface MotorConfig {
|
7 |
name: string;
|
@@ -17,7 +17,7 @@ export interface KeyState {
|
|
17 |
}
|
18 |
|
19 |
export interface UseTeleoperationOptions {
|
20 |
-
robot:
|
21 |
enabled: boolean;
|
22 |
onError?: (error: string) => void;
|
23 |
}
|
@@ -42,6 +42,7 @@ export interface UseTeleoperationResult {
|
|
42 |
goToHome: () => Promise<void>;
|
43 |
simulateKeyPress: (key: string) => void;
|
44 |
simulateKeyRelease: (key: string) => void;
|
|
|
45 |
}
|
46 |
|
47 |
const MOTOR_CONFIGS: MotorConfig[] = [
|
@@ -116,7 +117,8 @@ const KEYBOARD_CONTROLS = {
|
|
116 |
d: { motorIndex: 3, direction: 1, description: "Wrist flex right" },
|
117 |
q: { motorIndex: 4, direction: -1, description: "Wrist roll left" },
|
118 |
e: { motorIndex: 4, direction: 1, description: "Wrist roll right" },
|
119 |
-
|
|
|
120 |
Escape: { motorIndex: -1, direction: 0, description: "Emergency stop" },
|
121 |
};
|
122 |
|
@@ -471,6 +473,35 @@ export function useTeleoperation({
|
|
471 |
[isActive]
|
472 |
);
|
473 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
474 |
return {
|
475 |
isConnected: connection.isConnected,
|
476 |
isActive,
|
@@ -482,5 +513,6 @@ export function useTeleoperation({
|
|
482 |
goToHome,
|
483 |
simulateKeyPress,
|
484 |
simulateKeyRelease,
|
|
|
485 |
};
|
486 |
}
|
|
|
1 |
import { useState, useEffect, useCallback, useRef } from "react";
|
2 |
import { useRobotConnection } from "./useRobotConnection";
|
3 |
import { getUnifiedRobotData } from "../lib/unified-storage";
|
4 |
+
import type { RobotConnection } from "../../lerobot/web/find_port.js";
|
5 |
|
6 |
export interface MotorConfig {
|
7 |
name: string;
|
|
|
17 |
}
|
18 |
|
19 |
export interface UseTeleoperationOptions {
|
20 |
+
robot: RobotConnection;
|
21 |
enabled: boolean;
|
22 |
onError?: (error: string) => void;
|
23 |
}
|
|
|
42 |
goToHome: () => Promise<void>;
|
43 |
simulateKeyPress: (key: string) => void;
|
44 |
simulateKeyRelease: (key: string) => void;
|
45 |
+
moveMotorToPosition: (motorIndex: number, position: number) => Promise<void>;
|
46 |
}
|
47 |
|
48 |
const MOTOR_CONFIGS: MotorConfig[] = [
|
|
|
117 |
d: { motorIndex: 3, direction: 1, description: "Wrist flex right" },
|
118 |
q: { motorIndex: 4, direction: -1, description: "Wrist roll left" },
|
119 |
e: { motorIndex: 4, direction: 1, description: "Wrist roll right" },
|
120 |
+
o: { motorIndex: 5, direction: 1, description: "Gripper open" },
|
121 |
+
c: { motorIndex: 5, direction: -1, description: "Gripper close" },
|
122 |
Escape: { motorIndex: -1, direction: 0, description: "Emergency stop" },
|
123 |
};
|
124 |
|
|
|
473 |
[isActive]
|
474 |
);
|
475 |
|
476 |
+
const moveMotorToPosition = useCallback(
|
477 |
+
async (motorIndex: number, position: number) => {
|
478 |
+
if (!connection.isConnected) {
|
479 |
+
return;
|
480 |
+
}
|
481 |
+
|
482 |
+
try {
|
483 |
+
await connection.writeMotorPosition(motorIndex + 1, position);
|
484 |
+
|
485 |
+
// Update internal state
|
486 |
+
motorPositionsRef.current[motorIndex] = position;
|
487 |
+
|
488 |
+
setMotorConfigs((prev) =>
|
489 |
+
prev.map((config, index) => ({
|
490 |
+
...config,
|
491 |
+
currentPosition:
|
492 |
+
index === motorIndex ? position : config.currentPosition,
|
493 |
+
}))
|
494 |
+
);
|
495 |
+
} catch (error) {
|
496 |
+
console.warn(
|
497 |
+
`Failed to move motor ${motorIndex + 1} to position ${position}:`,
|
498 |
+
error
|
499 |
+
);
|
500 |
+
}
|
501 |
+
},
|
502 |
+
[connection.isConnected, connection.writeMotorPosition]
|
503 |
+
);
|
504 |
+
|
505 |
return {
|
506 |
isConnected: connection.isConnected,
|
507 |
isActive,
|
|
|
513 |
goToHome,
|
514 |
simulateKeyPress,
|
515 |
simulateKeyRelease,
|
516 |
+
moveMotorToPosition,
|
517 |
};
|
518 |
}
|
src/demo/pages/Home.tsx
CHANGED
@@ -12,12 +12,12 @@ import { PortManager } from "../components/PortManager";
|
|
12 |
import { CalibrationPanel } from "../components/CalibrationPanel";
|
13 |
import { TeleoperationPanel } from "../components/TeleoperationPanel";
|
14 |
import { isWebSerialSupported } from "../../lerobot/web/calibrate";
|
15 |
-
import type {
|
16 |
|
17 |
interface HomeProps {
|
18 |
onGetStarted: () => void;
|
19 |
-
connectedRobots:
|
20 |
-
onConnectedRobotsChange: (robots:
|
21 |
}
|
22 |
|
23 |
export function Home({
|
@@ -26,9 +26,9 @@ export function Home({
|
|
26 |
onConnectedRobotsChange,
|
27 |
}: HomeProps) {
|
28 |
const [calibratingRobot, setCalibratingRobot] =
|
29 |
-
useState<
|
30 |
const [teleoperatingRobot, setTeleoperatingRobot] =
|
31 |
-
useState<
|
32 |
const isSupported = isWebSerialSupported();
|
33 |
|
34 |
const handleCalibrate = (
|
@@ -43,7 +43,7 @@ export function Home({
|
|
43 |
}
|
44 |
};
|
45 |
|
46 |
-
const handleTeleoperate = (robot:
|
47 |
setTeleoperatingRobot(robot);
|
48 |
};
|
49 |
|
|
|
12 |
import { CalibrationPanel } from "../components/CalibrationPanel";
|
13 |
import { TeleoperationPanel } from "../components/TeleoperationPanel";
|
14 |
import { isWebSerialSupported } from "../../lerobot/web/calibrate";
|
15 |
+
import type { RobotConnection } from "../../lerobot/web/find_port.js";
|
16 |
|
17 |
interface HomeProps {
|
18 |
onGetStarted: () => void;
|
19 |
+
connectedRobots: RobotConnection[];
|
20 |
+
onConnectedRobotsChange: (robots: RobotConnection[]) => void;
|
21 |
}
|
22 |
|
23 |
export function Home({
|
|
|
26 |
onConnectedRobotsChange,
|
27 |
}: HomeProps) {
|
28 |
const [calibratingRobot, setCalibratingRobot] =
|
29 |
+
useState<RobotConnection | null>(null);
|
30 |
const [teleoperatingRobot, setTeleoperatingRobot] =
|
31 |
+
useState<RobotConnection | null>(null);
|
32 |
const isSupported = isWebSerialSupported();
|
33 |
|
34 |
const handleCalibrate = (
|
|
|
43 |
}
|
44 |
};
|
45 |
|
46 |
+
const handleTeleoperate = (robot: RobotConnection) => {
|
47 |
setTeleoperatingRobot(robot);
|
48 |
};
|
49 |
|
src/demo/types.ts
CHANGED
@@ -1,20 +1,2 @@
|
|
1 |
-
|
2 |
-
|
3 |
-
name: string;
|
4 |
-
isConnected: boolean;
|
5 |
-
robotType?: "so100_follower" | "so100_leader";
|
6 |
-
robotId?: string;
|
7 |
-
serialNumber?: string; // Unique identifier from USB device
|
8 |
-
usbMetadata?: {
|
9 |
-
vendorId: string;
|
10 |
-
productId: string;
|
11 |
-
serialNumber: string;
|
12 |
-
manufacturerName: string;
|
13 |
-
productName: string;
|
14 |
-
usbVersionMajor?: number;
|
15 |
-
usbVersionMinor?: number;
|
16 |
-
deviceClass?: number;
|
17 |
-
deviceSubclass?: number;
|
18 |
-
deviceProtocol?: number;
|
19 |
-
};
|
20 |
-
}
|
|
|
1 |
+
// Demo uses the standard library RobotConnection interface directly
|
2 |
+
// No need for separate types - just import RobotConnection where needed
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/lerobot/web/calibrate.ts
CHANGED
@@ -1,13 +1,29 @@
|
|
1 |
/**
|
2 |
* Web calibration functionality using Web Serial API
|
3 |
-
*
|
|
|
|
|
|
|
|
|
|
|
4 |
*/
|
5 |
|
6 |
-
import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
7 |
|
8 |
/**
|
9 |
-
* Device
|
10 |
-
*
|
11 |
*/
|
12 |
interface WebCalibrationConfig {
|
13 |
deviceType: "so100_follower" | "so100_leader";
|
@@ -15,9 +31,8 @@ interface WebCalibrationConfig {
|
|
15 |
motorNames: string[];
|
16 |
motorIds: number[];
|
17 |
driveModes: number[];
|
18 |
-
calibModes: string[];
|
19 |
|
20 |
-
// Protocol-specific configuration
|
21 |
protocol: {
|
22 |
resolution: number;
|
23 |
homingOffsetAddress: number;
|
@@ -30,13 +45,6 @@ interface WebCalibrationConfig {
|
|
30 |
maxPositionLimitLength: number;
|
31 |
signMagnitudeBit: number;
|
32 |
};
|
33 |
-
|
34 |
-
limits: {
|
35 |
-
position_min: number[];
|
36 |
-
position_max: number[];
|
37 |
-
velocity_max: number[];
|
38 |
-
torque_max: number[];
|
39 |
-
};
|
40 |
}
|
41 |
|
42 |
/**
|
@@ -53,470 +61,34 @@ export interface WebCalibrationResults {
|
|
53 |
}
|
54 |
|
55 |
/**
|
56 |
-
*
|
57 |
-
*/
|
58 |
-
const WEB_STS3215_PROTOCOL = {
|
59 |
-
resolution: 4096, // 12-bit resolution (0-4095)
|
60 |
-
homingOffsetAddress: 31, // Address for Homing_Offset register
|
61 |
-
homingOffsetLength: 2, // 2 bytes for Homing_Offset
|
62 |
-
presentPositionAddress: 56, // Address for Present_Position register
|
63 |
-
presentPositionLength: 2, // 2 bytes for Present_Position
|
64 |
-
minPositionLimitAddress: 9, // Address for Min_Position_Limit register
|
65 |
-
minPositionLimitLength: 2, // 2 bytes for Min_Position_Limit
|
66 |
-
maxPositionLimitAddress: 11, // Address for Max_Position_Limit register
|
67 |
-
maxPositionLimitLength: 2, // 2 bytes for Max_Position_Limit
|
68 |
-
signMagnitudeBit: 11, // Bit 11 is sign bit for Homing_Offset encoding
|
69 |
-
} as const;
|
70 |
-
|
71 |
-
/**
|
72 |
-
* Sign-magnitude encoding functions (matches Node.js exactly)
|
73 |
-
*/
|
74 |
-
function encodeSignMagnitude(value: number, signBitIndex: number): number {
|
75 |
-
const maxMagnitude = (1 << signBitIndex) - 1;
|
76 |
-
const magnitude = Math.abs(value);
|
77 |
-
|
78 |
-
if (magnitude > maxMagnitude) {
|
79 |
-
throw new Error(
|
80 |
-
`Magnitude ${magnitude} exceeds ${maxMagnitude} (max for signBitIndex=${signBitIndex})`
|
81 |
-
);
|
82 |
-
}
|
83 |
-
|
84 |
-
const directionBit = value < 0 ? 1 : 0;
|
85 |
-
return (directionBit << signBitIndex) | magnitude;
|
86 |
-
}
|
87 |
-
|
88 |
-
/**
|
89 |
-
* PROPER Web Serial Port wrapper following Chrome documentation exactly
|
90 |
-
* Direct write/read with immediate lock release - NO persistent connections
|
91 |
-
*/
|
92 |
-
class WebSerialPortWrapper {
|
93 |
-
private port: SerialPort;
|
94 |
-
|
95 |
-
constructor(port: SerialPort) {
|
96 |
-
this.port = port;
|
97 |
-
}
|
98 |
-
|
99 |
-
get isOpen(): boolean {
|
100 |
-
return (
|
101 |
-
this.port !== null &&
|
102 |
-
this.port.readable !== null &&
|
103 |
-
this.port.writable !== null
|
104 |
-
);
|
105 |
-
}
|
106 |
-
|
107 |
-
async initialize(): Promise<void> {
|
108 |
-
if (!this.port.readable || !this.port.writable) {
|
109 |
-
throw new Error("Port is not open for reading/writing");
|
110 |
-
}
|
111 |
-
}
|
112 |
-
|
113 |
-
/**
|
114 |
-
* Write data - EXACTLY like Chrome documentation
|
115 |
-
* Get writer, write, release lock immediately
|
116 |
-
*/
|
117 |
-
async write(data: Uint8Array): Promise<void> {
|
118 |
-
if (!this.port.writable) {
|
119 |
-
throw new Error("Port not open for writing");
|
120 |
-
}
|
121 |
-
|
122 |
-
// Write packet to motor
|
123 |
-
|
124 |
-
const writer = this.port.writable.getWriter();
|
125 |
-
try {
|
126 |
-
await writer.write(data);
|
127 |
-
} finally {
|
128 |
-
writer.releaseLock();
|
129 |
-
}
|
130 |
-
}
|
131 |
-
|
132 |
-
/**
|
133 |
-
* Read data - EXACTLY like Chrome documentation
|
134 |
-
* Get reader, read once, release lock immediately
|
135 |
-
*/
|
136 |
-
async read(timeout: number = 1000): Promise<Uint8Array> {
|
137 |
-
if (!this.port.readable) {
|
138 |
-
throw new Error("Port not open for reading");
|
139 |
-
}
|
140 |
-
|
141 |
-
const reader = this.port.readable.getReader();
|
142 |
-
|
143 |
-
try {
|
144 |
-
// Set up timeout
|
145 |
-
const timeoutPromise = new Promise<never>((_, reject) => {
|
146 |
-
setTimeout(() => reject(new Error("Read timeout")), timeout);
|
147 |
-
});
|
148 |
-
|
149 |
-
// Race between read and timeout
|
150 |
-
const result = await Promise.race([reader.read(), timeoutPromise]);
|
151 |
-
|
152 |
-
const { value, done } = result;
|
153 |
-
|
154 |
-
if (done || !value) {
|
155 |
-
throw new Error("Read failed - port closed or no data");
|
156 |
-
}
|
157 |
-
|
158 |
-
const response = new Uint8Array(value);
|
159 |
-
return response;
|
160 |
-
} finally {
|
161 |
-
reader.releaseLock();
|
162 |
-
}
|
163 |
-
}
|
164 |
-
|
165 |
-
async close(): Promise<void> {
|
166 |
-
// Don't close the port itself - just wrapper cleanup
|
167 |
-
}
|
168 |
-
}
|
169 |
-
|
170 |
-
/**
|
171 |
-
* Read motor positions using device-agnostic configuration (exactly like Node.js)
|
172 |
-
*/
|
173 |
-
async function readMotorPositions(
|
174 |
-
config: WebCalibrationConfig
|
175 |
-
): Promise<number[]> {
|
176 |
-
const motorPositions: number[] = [];
|
177 |
-
|
178 |
-
// Reading motor positions
|
179 |
-
|
180 |
-
for (let i = 0; i < config.motorIds.length; i++) {
|
181 |
-
const motorId = config.motorIds[i];
|
182 |
-
const motorName = config.motorNames[i];
|
183 |
-
|
184 |
-
try {
|
185 |
-
// Create Read Position packet using configurable address
|
186 |
-
const packet = new Uint8Array([
|
187 |
-
0xff,
|
188 |
-
0xff,
|
189 |
-
motorId,
|
190 |
-
0x04,
|
191 |
-
0x02,
|
192 |
-
config.protocol.presentPositionAddress, // Configurable address
|
193 |
-
0x02,
|
194 |
-
0x00,
|
195 |
-
]);
|
196 |
-
const checksum =
|
197 |
-
~(
|
198 |
-
motorId +
|
199 |
-
0x04 +
|
200 |
-
0x02 +
|
201 |
-
config.protocol.presentPositionAddress +
|
202 |
-
0x02
|
203 |
-
) & 0xff;
|
204 |
-
packet[7] = checksum;
|
205 |
-
|
206 |
-
// Professional Feetech communication pattern (based on matthieuvigne/STS_servos)
|
207 |
-
let attempts = 0;
|
208 |
-
let success = false;
|
209 |
-
|
210 |
-
while (attempts < 3 && !success) {
|
211 |
-
attempts++;
|
212 |
-
|
213 |
-
// Clear any remaining data in buffer first (critical for Web Serial)
|
214 |
-
try {
|
215 |
-
await config.port.read(0); // Non-blocking read to clear buffer
|
216 |
-
} catch (e) {
|
217 |
-
// Expected - buffer was empty
|
218 |
-
}
|
219 |
-
|
220 |
-
// Write command with proper timing
|
221 |
-
await config.port.write(packet);
|
222 |
-
|
223 |
-
// Arduino library uses careful timing - Web Serial needs more
|
224 |
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
225 |
-
|
226 |
-
const response = await config.port.read(150);
|
227 |
-
|
228 |
-
if (response.length >= 7) {
|
229 |
-
const id = response[2];
|
230 |
-
const error = response[4];
|
231 |
-
|
232 |
-
if (id === motorId && error === 0) {
|
233 |
-
const position = response[5] | (response[6] << 8);
|
234 |
-
motorPositions.push(position);
|
235 |
-
success = true;
|
236 |
-
} else if (id === motorId && error !== 0) {
|
237 |
-
// Motor error, retry
|
238 |
-
} else {
|
239 |
-
// Wrong response ID, retry
|
240 |
-
}
|
241 |
-
} else {
|
242 |
-
// Short response, retry
|
243 |
-
}
|
244 |
-
|
245 |
-
// Professional timing between attempts (like Arduino libraries)
|
246 |
-
if (!success && attempts < 3) {
|
247 |
-
await new Promise((resolve) => setTimeout(resolve, 20));
|
248 |
-
}
|
249 |
-
}
|
250 |
-
|
251 |
-
// If all attempts failed, use fallback
|
252 |
-
if (!success) {
|
253 |
-
const fallback = Math.floor((config.protocol.resolution - 1) / 2);
|
254 |
-
motorPositions.push(fallback);
|
255 |
-
}
|
256 |
-
} catch (error) {
|
257 |
-
const fallback = Math.floor((config.protocol.resolution - 1) / 2);
|
258 |
-
motorPositions.push(fallback);
|
259 |
-
}
|
260 |
-
|
261 |
-
// Professional inter-motor delay (based on Arduino STS_servos library)
|
262 |
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
263 |
-
}
|
264 |
-
|
265 |
-
return motorPositions;
|
266 |
-
}
|
267 |
-
|
268 |
-
/**
|
269 |
-
* Reset homing offsets to 0 for all motors (matches Node.js exactly)
|
270 |
-
*/
|
271 |
-
async function resetHomingOffsets(config: WebCalibrationConfig): Promise<void> {
|
272 |
-
for (let i = 0; i < config.motorIds.length; i++) {
|
273 |
-
const motorId = config.motorIds[i];
|
274 |
-
const motorName = config.motorNames[i];
|
275 |
-
|
276 |
-
try {
|
277 |
-
const homingOffsetValue = 0;
|
278 |
-
|
279 |
-
// Create Write Homing_Offset packet using configurable address
|
280 |
-
const packet = new Uint8Array([
|
281 |
-
0xff,
|
282 |
-
0xff, // Header
|
283 |
-
motorId, // Servo ID
|
284 |
-
0x05, // Length
|
285 |
-
0x03, // Instruction: WRITE_DATA
|
286 |
-
config.protocol.homingOffsetAddress, // Configurable address
|
287 |
-
homingOffsetValue & 0xff, // Data_L (low byte)
|
288 |
-
(homingOffsetValue >> 8) & 0xff, // Data_H (high byte)
|
289 |
-
0x00, // Checksum (will calculate)
|
290 |
-
]);
|
291 |
-
|
292 |
-
// Calculate checksum using configurable address
|
293 |
-
const checksum =
|
294 |
-
~(
|
295 |
-
motorId +
|
296 |
-
0x05 +
|
297 |
-
0x03 +
|
298 |
-
config.protocol.homingOffsetAddress +
|
299 |
-
(homingOffsetValue & 0xff) +
|
300 |
-
((homingOffsetValue >> 8) & 0xff)
|
301 |
-
) & 0xff;
|
302 |
-
packet[8] = checksum;
|
303 |
-
|
304 |
-
// Simple write then read like Node.js
|
305 |
-
await config.port.write(packet);
|
306 |
-
|
307 |
-
// Wait for response (silent unless error)
|
308 |
-
try {
|
309 |
-
await config.port.read(200);
|
310 |
-
} catch (error) {
|
311 |
-
// Silent - response not required for successful operation
|
312 |
-
}
|
313 |
-
} catch (error) {
|
314 |
-
throw new Error(
|
315 |
-
`Failed to reset homing offset for ${motorName}: ${
|
316 |
-
error instanceof Error ? error.message : error
|
317 |
-
}`
|
318 |
-
);
|
319 |
-
}
|
320 |
-
}
|
321 |
-
}
|
322 |
-
|
323 |
-
/**
|
324 |
-
* Write homing offsets to motor registers immediately (matches Node.js exactly)
|
325 |
-
*/
|
326 |
-
async function writeHomingOffsetsToMotors(
|
327 |
-
config: WebCalibrationConfig,
|
328 |
-
homingOffsets: { [motor: string]: number }
|
329 |
-
): Promise<void> {
|
330 |
-
for (let i = 0; i < config.motorIds.length; i++) {
|
331 |
-
const motorId = config.motorIds[i];
|
332 |
-
const motorName = config.motorNames[i];
|
333 |
-
const homingOffset = homingOffsets[motorName];
|
334 |
-
|
335 |
-
try {
|
336 |
-
// Encode using sign-magnitude format
|
337 |
-
const encodedOffset = encodeSignMagnitude(
|
338 |
-
homingOffset,
|
339 |
-
config.protocol.signMagnitudeBit
|
340 |
-
);
|
341 |
-
|
342 |
-
// Create Write Homing_Offset packet
|
343 |
-
const packet = new Uint8Array([
|
344 |
-
0xff,
|
345 |
-
0xff, // Header
|
346 |
-
motorId, // Servo ID
|
347 |
-
0x05, // Length
|
348 |
-
0x03, // Instruction: WRITE_DATA
|
349 |
-
config.protocol.homingOffsetAddress, // Homing_Offset address
|
350 |
-
encodedOffset & 0xff, // Data_L (low byte)
|
351 |
-
(encodedOffset >> 8) & 0xff, // Data_H (high byte)
|
352 |
-
0x00, // Checksum (will calculate)
|
353 |
-
]);
|
354 |
-
|
355 |
-
// Calculate checksum
|
356 |
-
const checksum =
|
357 |
-
~(
|
358 |
-
motorId +
|
359 |
-
0x05 +
|
360 |
-
0x03 +
|
361 |
-
config.protocol.homingOffsetAddress +
|
362 |
-
(encodedOffset & 0xff) +
|
363 |
-
((encodedOffset >> 8) & 0xff)
|
364 |
-
) & 0xff;
|
365 |
-
packet[8] = checksum;
|
366 |
-
|
367 |
-
// Simple write then read like Node.js
|
368 |
-
await config.port.write(packet);
|
369 |
-
|
370 |
-
// Wait for response (silent unless error)
|
371 |
-
try {
|
372 |
-
await config.port.read(200);
|
373 |
-
} catch (error) {
|
374 |
-
// Silent - response not required for successful operation
|
375 |
-
}
|
376 |
-
} catch (error) {
|
377 |
-
throw new Error(
|
378 |
-
`Failed to write homing offset for ${motorName}: ${
|
379 |
-
error instanceof Error ? error.message : error
|
380 |
-
}`
|
381 |
-
);
|
382 |
-
}
|
383 |
-
}
|
384 |
-
}
|
385 |
-
|
386 |
-
/**
|
387 |
-
* Record homing offsets with immediate writing (matches Node.js exactly)
|
388 |
*/
|
389 |
-
|
390 |
-
|
391 |
-
|
392 |
-
|
393 |
-
|
394 |
-
|
395 |
-
|
396 |
-
|
397 |
-
// Wait a moment for reset to take effect
|
398 |
-
await new Promise((resolve) => setTimeout(resolve, 100));
|
399 |
-
|
400 |
-
// Now read positions (which will be true physical positions)
|
401 |
-
const currentPositions = await readMotorPositions(config);
|
402 |
-
const homingOffsets: { [motor: string]: number } = {};
|
403 |
-
|
404 |
-
const halfTurn = Math.floor((config.protocol.resolution - 1) / 2);
|
405 |
-
|
406 |
-
for (let i = 0; i < config.motorNames.length; i++) {
|
407 |
-
const motorName = config.motorNames[i];
|
408 |
-
const position = currentPositions[i];
|
409 |
-
|
410 |
-
// Generic formula: pos - int((max_res - 1) / 2) using configurable resolution
|
411 |
-
const homingOffset = position - halfTurn;
|
412 |
-
homingOffsets[motorName] = homingOffset;
|
413 |
-
}
|
414 |
-
|
415 |
-
// CRITICAL: Write homing offsets to motors immediately (matching Python exactly)
|
416 |
-
await writeHomingOffsetsToMotors(config, homingOffsets);
|
417 |
-
|
418 |
-
return homingOffsets;
|
419 |
-
}
|
420 |
-
|
421 |
-
/**
|
422 |
-
* Generic function to write a 2-byte value to a motor register (matches Node.js exactly)
|
423 |
-
*/
|
424 |
-
async function writeMotorRegister(
|
425 |
-
config: WebCalibrationConfig,
|
426 |
-
motorId: number,
|
427 |
-
registerAddress: number,
|
428 |
-
value: number,
|
429 |
-
description: string
|
430 |
-
): Promise<void> {
|
431 |
-
// Create Write Register packet
|
432 |
-
const packet = new Uint8Array([
|
433 |
-
0xff,
|
434 |
-
0xff, // Header
|
435 |
-
motorId, // Servo ID
|
436 |
-
0x05, // Length
|
437 |
-
0x03, // Instruction: WRITE_DATA
|
438 |
-
registerAddress, // Register address
|
439 |
-
value & 0xff, // Data_L (low byte)
|
440 |
-
(value >> 8) & 0xff, // Data_H (high byte)
|
441 |
-
0x00, // Checksum (will calculate)
|
442 |
-
]);
|
443 |
-
|
444 |
-
// Calculate checksum
|
445 |
-
const checksum =
|
446 |
-
~(
|
447 |
-
motorId +
|
448 |
-
0x05 +
|
449 |
-
0x03 +
|
450 |
-
registerAddress +
|
451 |
-
(value & 0xff) +
|
452 |
-
((value >> 8) & 0xff)
|
453 |
-
) & 0xff;
|
454 |
-
packet[8] = checksum;
|
455 |
-
|
456 |
-
// Simple write then read like Node.js
|
457 |
-
await config.port.write(packet);
|
458 |
-
|
459 |
-
// Wait for response (silent unless error)
|
460 |
-
try {
|
461 |
-
await config.port.read(200);
|
462 |
-
} catch (error) {
|
463 |
-
// Silent - response not required for successful operation
|
464 |
-
}
|
465 |
}
|
466 |
|
467 |
/**
|
468 |
-
*
|
469 |
*/
|
470 |
-
|
471 |
-
|
472 |
-
|
473 |
-
rangeMaxes: { [motor: string]: number }
|
474 |
-
): Promise<void> {
|
475 |
-
for (let i = 0; i < config.motorIds.length; i++) {
|
476 |
-
const motorId = config.motorIds[i];
|
477 |
-
const motorName = config.motorNames[i];
|
478 |
-
const minLimit = rangeMins[motorName];
|
479 |
-
const maxLimit = rangeMaxes[motorName];
|
480 |
-
|
481 |
-
try {
|
482 |
-
// Write Min_Position_Limit register
|
483 |
-
await writeMotorRegister(
|
484 |
-
config,
|
485 |
-
motorId,
|
486 |
-
config.protocol.minPositionLimitAddress,
|
487 |
-
minLimit,
|
488 |
-
`Min_Position_Limit for ${motorName}`
|
489 |
-
);
|
490 |
-
|
491 |
-
// Write Max_Position_Limit register
|
492 |
-
await writeMotorRegister(
|
493 |
-
config,
|
494 |
-
motorId,
|
495 |
-
config.protocol.maxPositionLimitAddress,
|
496 |
-
maxLimit,
|
497 |
-
`Max_Position_Limit for ${motorName}`
|
498 |
-
);
|
499 |
-
} catch (error) {
|
500 |
-
throw new Error(
|
501 |
-
`Failed to write position limits for ${motorName}: ${
|
502 |
-
error instanceof Error ? error.message : error
|
503 |
-
}`
|
504 |
-
);
|
505 |
-
}
|
506 |
-
}
|
507 |
}
|
508 |
|
509 |
/**
|
510 |
-
* Record ranges of motion with
|
511 |
*/
|
512 |
async function recordRangesOfMotion(
|
513 |
-
|
|
|
|
|
514 |
shouldStop: () => boolean,
|
515 |
-
|
516 |
-
rangeMins: { [motor: string]: number },
|
517 |
-
rangeMaxes: { [motor: string]: number },
|
518 |
-
currentPositions: { [motor: string]: number }
|
519 |
-
) => void
|
520 |
): Promise<{
|
521 |
rangeMins: { [motor: string]: number };
|
522 |
rangeMaxes: { [motor: string]: number };
|
@@ -525,29 +97,23 @@ async function recordRangesOfMotion(
|
|
525 |
const rangeMaxes: { [motor: string]: number } = {};
|
526 |
|
527 |
// Read actual current positions (matching Python exactly)
|
528 |
-
|
529 |
-
const startPositions = await readMotorPositions(config);
|
530 |
|
531 |
-
for (let i = 0; i <
|
532 |
-
const motorName =
|
533 |
const startPosition = startPositions[i];
|
534 |
-
rangeMins[motorName] = startPosition;
|
535 |
-
rangeMaxes[motorName] = startPosition;
|
536 |
}
|
537 |
|
538 |
-
//
|
539 |
-
let recordingCount = 0;
|
540 |
-
|
541 |
while (!shouldStop()) {
|
542 |
try {
|
543 |
-
const positions = await
|
544 |
-
recordingCount++;
|
545 |
|
546 |
-
for (let i = 0; i <
|
547 |
-
const motorName =
|
548 |
const position = positions[i];
|
549 |
-
const oldMin = rangeMins[motorName];
|
550 |
-
const oldMax = rangeMaxes[motorName];
|
551 |
|
552 |
if (position < rangeMins[motorName]) {
|
553 |
rangeMins[motorName] = position;
|
@@ -555,275 +121,141 @@ async function recordRangesOfMotion(
|
|
555 |
if (position > rangeMaxes[motorName]) {
|
556 |
rangeMaxes[motorName] = position;
|
557 |
}
|
558 |
-
|
559 |
-
// Track range expansions silently
|
560 |
}
|
561 |
|
562 |
-
//
|
563 |
-
|
564 |
-
|
565 |
-
|
566 |
-
|
567 |
-
|
568 |
-
|
569 |
-
|
|
|
|
|
|
|
570 |
}
|
571 |
-
|
572 |
}
|
573 |
} catch (error) {
|
574 |
-
|
575 |
}
|
576 |
|
577 |
-
// 20fps reading rate for stable Web Serial communication
|
578 |
await new Promise((resolve) => setTimeout(resolve, 50));
|
579 |
}
|
580 |
|
581 |
-
// Range recording finished
|
582 |
-
|
583 |
return { rangeMins, rangeMaxes };
|
584 |
}
|
585 |
|
586 |
/**
|
587 |
-
*
|
588 |
*/
|
589 |
-
|
590 |
-
|
591 |
-
|
592 |
-
|
593 |
-
|
594 |
-
rangeMaxes: { [motor: string]: number },
|
595 |
-
currentPositions: { [motor: string]: number }
|
596 |
-
) => void
|
597 |
-
): Promise<WebCalibrationResults> {
|
598 |
-
// Step 1: Set homing position
|
599 |
-
const homingOffsets = await setHomingOffsets(config);
|
600 |
-
|
601 |
-
// Step 2: Record ranges of motion with manual control
|
602 |
-
const { rangeMins, rangeMaxes } = await recordRangesOfMotion(
|
603 |
-
config,
|
604 |
-
shouldStopRecording,
|
605 |
-
onRangeUpdate
|
606 |
-
);
|
607 |
-
|
608 |
-
// Step 3: Set special range for wrist_roll (full turn motor)
|
609 |
-
rangeMins["wrist_roll"] = 0;
|
610 |
-
rangeMaxes["wrist_roll"] = 4095;
|
611 |
-
|
612 |
-
// Step 4: Write hardware position limits to motors (matching Python behavior)
|
613 |
-
await writeHardwarePositionLimits(config, rangeMins, rangeMaxes);
|
614 |
-
|
615 |
-
// Compile results in Python-compatible format (NOT array format!)
|
616 |
-
const results: WebCalibrationResults = {};
|
617 |
-
|
618 |
-
for (let i = 0; i < config.motorNames.length; i++) {
|
619 |
-
const motorName = config.motorNames[i];
|
620 |
-
const motorId = config.motorIds[i];
|
621 |
-
|
622 |
-
results[motorName] = {
|
623 |
-
id: motorId,
|
624 |
-
drive_mode: config.driveModes[i],
|
625 |
-
homing_offset: homingOffsets[motorName],
|
626 |
-
range_min: rangeMins[motorName],
|
627 |
-
range_max: rangeMaxes[motorName],
|
628 |
-
};
|
629 |
-
}
|
630 |
|
631 |
-
return
|
|
|
|
|
|
|
632 |
}
|
633 |
|
634 |
/**
|
635 |
-
*
|
|
|
|
|
|
|
636 |
*/
|
637 |
-
export
|
638 |
-
|
639 |
-
|
640 |
-
|
641 |
-
|
642 |
-
|
643 |
-
constructor(config: WebCalibrationConfig) {
|
644 |
-
this.config = config;
|
645 |
}
|
646 |
-
|
647 |
-
|
648 |
-
|
|
|
|
|
|
|
649 |
}
|
650 |
|
651 |
-
|
652 |
-
|
653 |
-
|
654 |
|
655 |
-
|
656 |
-
|
657 |
-
|
658 |
-
|
659 |
-
|
660 |
-
|
661 |
-
|
662 |
-
): Promise<
|
663 |
-
|
664 |
-
|
665 |
-
|
666 |
-
|
|
|
|
|
667 |
);
|
668 |
-
this.rangeMins = rangeMins;
|
669 |
-
this.rangeMaxes = rangeMaxes;
|
670 |
|
671 |
-
//
|
672 |
-
|
673 |
-
|
674 |
-
|
|
|
|
|
|
|
|
|
675 |
|
676 |
-
|
677 |
-
|
678 |
-
|
679 |
-
|
|
|
|
|
680 |
|
681 |
-
// Write hardware position limits to motors
|
682 |
await writeHardwarePositionLimits(
|
683 |
-
|
684 |
-
|
685 |
-
|
|
|
|
|
686 |
);
|
687 |
|
688 |
-
// Compile results in Python-compatible format
|
689 |
const results: WebCalibrationResults = {};
|
690 |
|
691 |
-
for (let i = 0; i <
|
692 |
-
const motorName =
|
693 |
-
const motorId =
|
694 |
|
695 |
results[motorName] = {
|
696 |
id: motorId,
|
697 |
-
drive_mode:
|
698 |
-
homing_offset:
|
699 |
-
range_min:
|
700 |
-
range_max:
|
701 |
};
|
702 |
}
|
703 |
|
704 |
-
console.log("🎉 Calibration completed successfully!");
|
705 |
return results;
|
706 |
-
}
|
707 |
-
}
|
708 |
|
709 |
-
|
710 |
-
* Create SO-100 web configuration (matches Node.js exactly)
|
711 |
-
*/
|
712 |
-
function createSO100WebConfig(
|
713 |
-
deviceType: "so100_follower" | "so100_leader",
|
714 |
-
port: WebSerialPortWrapper
|
715 |
-
): WebCalibrationConfig {
|
716 |
return {
|
717 |
-
|
718 |
-
|
719 |
-
motorNames: [
|
720 |
-
"shoulder_pan",
|
721 |
-
"shoulder_lift",
|
722 |
-
"elbow_flex",
|
723 |
-
"wrist_flex",
|
724 |
-
"wrist_roll",
|
725 |
-
"gripper",
|
726 |
-
],
|
727 |
-
motorIds: [1, 2, 3, 4, 5, 6],
|
728 |
-
protocol: WEB_STS3215_PROTOCOL,
|
729 |
-
driveModes: [0, 0, 0, 0, 0, 0], // Python lerobot uses drive_mode=0 for all motors
|
730 |
-
calibModes: ["DEGREE", "DEGREE", "DEGREE", "DEGREE", "DEGREE", "LINEAR"],
|
731 |
-
limits: {
|
732 |
-
position_min: [-180, -90, -90, -90, -90, -90],
|
733 |
-
position_max: [180, 90, 90, 90, 90, 90],
|
734 |
-
velocity_max: [100, 100, 100, 100, 100, 100],
|
735 |
-
torque_max: [50, 50, 50, 50, 25, 25],
|
736 |
},
|
|
|
737 |
};
|
738 |
}
|
739 |
|
740 |
-
/**
|
741 |
-
* Create a calibration controller for step-by-step calibration in React components
|
742 |
-
*/
|
743 |
-
export async function createCalibrationController(
|
744 |
-
armType: "so100_follower" | "so100_leader",
|
745 |
-
connectedPort: SerialPort
|
746 |
-
): Promise<WebCalibrationController> {
|
747 |
-
// Create web serial port wrapper
|
748 |
-
const port = new WebSerialPortWrapper(connectedPort);
|
749 |
-
await port.initialize();
|
750 |
-
|
751 |
-
// Get device-agnostic calibration configuration
|
752 |
-
const config = createSO100WebConfig(armType, port);
|
753 |
-
|
754 |
-
return new WebCalibrationController(config);
|
755 |
-
}
|
756 |
-
|
757 |
-
/**
|
758 |
-
* Save calibration results to unified storage system
|
759 |
-
*/
|
760 |
-
export async function saveCalibrationResults(
|
761 |
-
calibrationResults: WebCalibrationResults,
|
762 |
-
armType: "so100_follower" | "so100_leader",
|
763 |
-
armId: string,
|
764 |
-
serialNumber: string,
|
765 |
-
recordingCount: number = 0
|
766 |
-
): Promise<void> {
|
767 |
-
// Prepare full calibration data
|
768 |
-
const fullCalibrationData = {
|
769 |
-
...calibrationResults,
|
770 |
-
device_type: armType,
|
771 |
-
device_id: armId,
|
772 |
-
calibrated_at: new Date().toISOString(),
|
773 |
-
platform: "web",
|
774 |
-
api: "Web Serial API",
|
775 |
-
};
|
776 |
-
|
777 |
-
const metadata = {
|
778 |
-
timestamp: new Date().toISOString(),
|
779 |
-
readCount: recordingCount,
|
780 |
-
};
|
781 |
-
|
782 |
-
// Try to save using unified storage system
|
783 |
-
try {
|
784 |
-
const { saveCalibrationData } = await import(
|
785 |
-
"../../demo/lib/unified-storage.js"
|
786 |
-
);
|
787 |
-
saveCalibrationData(serialNumber, fullCalibrationData, metadata);
|
788 |
-
console.log(
|
789 |
-
`✅ Calibration saved to unified storage: lerobotjs-${serialNumber}`
|
790 |
-
);
|
791 |
-
} catch (error) {
|
792 |
-
console.warn(
|
793 |
-
"Failed to save to unified storage, falling back to old format:",
|
794 |
-
error
|
795 |
-
);
|
796 |
-
|
797 |
-
// Fallback to old storage format for compatibility
|
798 |
-
const fullDataKey = `lerobot_calibration_${armType}_${armId}`;
|
799 |
-
localStorage.setItem(fullDataKey, JSON.stringify(fullCalibrationData));
|
800 |
-
|
801 |
-
const dashboardKey = `lerobot-calibration-${serialNumber}`;
|
802 |
-
localStorage.setItem(dashboardKey, JSON.stringify(metadata));
|
803 |
-
|
804 |
-
console.log(`📊 Dashboard data saved to: ${dashboardKey}`);
|
805 |
-
console.log(`🔧 Full calibration data saved to: ${fullDataKey}`);
|
806 |
-
}
|
807 |
-
}
|
808 |
-
|
809 |
-
/**
|
810 |
-
* Download calibration data as JSON file
|
811 |
-
*/
|
812 |
-
function downloadCalibrationFile(calibrationData: any, deviceId: string): void {
|
813 |
-
const dataStr = JSON.stringify(calibrationData, null, 2);
|
814 |
-
const dataBlob = new Blob([dataStr], { type: "application/json" });
|
815 |
-
|
816 |
-
const url = URL.createObjectURL(dataBlob);
|
817 |
-
const link = document.createElement("a");
|
818 |
-
link.href = url;
|
819 |
-
link.download = `${deviceId}_calibration.json`;
|
820 |
-
|
821 |
-
document.body.appendChild(link);
|
822 |
-
link.click();
|
823 |
-
document.body.removeChild(link);
|
824 |
-
URL.revokeObjectURL(url);
|
825 |
-
}
|
826 |
-
|
827 |
/**
|
828 |
* Check if Web Serial API is supported
|
829 |
*/
|
|
|
1 |
/**
|
2 |
* Web calibration functionality using Web Serial API
|
3 |
+
* Minimal library - does calibration, user handles storage/UI/etc.
|
4 |
+
*
|
5 |
+
* Currently supports SO-100 robots. Other robot types can be added by:
|
6 |
+
* 1. Creating robot-specific config files in ./robots/
|
7 |
+
* 2. Extending the calibrate() function to accept different robot types
|
8 |
+
* 3. Adding robot-specific protocol configurations
|
9 |
*/
|
10 |
|
11 |
+
import { createSO100Config } from "./robots/so100_config.js";
|
12 |
+
import { WebSerialPortWrapper } from "./utils/serial-port-wrapper.js";
|
13 |
+
import {
|
14 |
+
readAllMotorPositions,
|
15 |
+
releaseMotors,
|
16 |
+
type MotorCommunicationPort,
|
17 |
+
} from "./utils/motor-communication.js";
|
18 |
+
import {
|
19 |
+
setHomingOffsets,
|
20 |
+
writeHardwarePositionLimits,
|
21 |
+
} from "./utils/motor-calibration.js";
|
22 |
+
import type { RobotConnection } from "./find_port.js";
|
23 |
|
24 |
/**
|
25 |
+
* Device calibration configuration interface
|
26 |
+
* Currently designed for SO-100, but can be extended for other robot types
|
27 |
*/
|
28 |
interface WebCalibrationConfig {
|
29 |
deviceType: "so100_follower" | "so100_leader";
|
|
|
31 |
motorNames: string[];
|
32 |
motorIds: number[];
|
33 |
driveModes: number[];
|
|
|
34 |
|
35 |
+
// Protocol-specific configuration
|
36 |
protocol: {
|
37 |
resolution: number;
|
38 |
homingOffsetAddress: number;
|
|
|
45 |
maxPositionLimitLength: number;
|
46 |
signMagnitudeBit: number;
|
47 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
48 |
}
|
49 |
|
50 |
/**
|
|
|
61 |
}
|
62 |
|
63 |
/**
|
64 |
+
* Live calibration data with current positions and ranges
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
65 |
*/
|
66 |
+
export interface LiveCalibrationData {
|
67 |
+
[motorName: string]: {
|
68 |
+
current: number;
|
69 |
+
min: number;
|
70 |
+
max: number;
|
71 |
+
range: number;
|
72 |
+
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
73 |
}
|
74 |
|
75 |
/**
|
76 |
+
* Calibration process control object
|
77 |
*/
|
78 |
+
export interface CalibrationProcess {
|
79 |
+
stop(): void;
|
80 |
+
result: Promise<WebCalibrationResults>;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
81 |
}
|
82 |
|
83 |
/**
|
84 |
+
* Record ranges of motion with live updates
|
85 |
*/
|
86 |
async function recordRangesOfMotion(
|
87 |
+
port: MotorCommunicationPort,
|
88 |
+
motorIds: number[],
|
89 |
+
motorNames: string[],
|
90 |
shouldStop: () => boolean,
|
91 |
+
onLiveUpdate?: (data: LiveCalibrationData) => void
|
|
|
|
|
|
|
|
|
92 |
): Promise<{
|
93 |
rangeMins: { [motor: string]: number };
|
94 |
rangeMaxes: { [motor: string]: number };
|
|
|
97 |
const rangeMaxes: { [motor: string]: number } = {};
|
98 |
|
99 |
// Read actual current positions (matching Python exactly)
|
100 |
+
const startPositions = await readAllMotorPositions(port, motorIds);
|
|
|
101 |
|
102 |
+
for (let i = 0; i < motorNames.length; i++) {
|
103 |
+
const motorName = motorNames[i];
|
104 |
const startPosition = startPositions[i];
|
105 |
+
rangeMins[motorName] = startPosition;
|
106 |
+
rangeMaxes[motorName] = startPosition;
|
107 |
}
|
108 |
|
109 |
+
// Recording loop
|
|
|
|
|
110 |
while (!shouldStop()) {
|
111 |
try {
|
112 |
+
const positions = await readAllMotorPositions(port, motorIds);
|
|
|
113 |
|
114 |
+
for (let i = 0; i < motorNames.length; i++) {
|
115 |
+
const motorName = motorNames[i];
|
116 |
const position = positions[i];
|
|
|
|
|
117 |
|
118 |
if (position < rangeMins[motorName]) {
|
119 |
rangeMins[motorName] = position;
|
|
|
121 |
if (position > rangeMaxes[motorName]) {
|
122 |
rangeMaxes[motorName] = position;
|
123 |
}
|
|
|
|
|
124 |
}
|
125 |
|
126 |
+
// Call live update callback if provided
|
127 |
+
if (onLiveUpdate) {
|
128 |
+
const liveData: LiveCalibrationData = {};
|
129 |
+
for (let i = 0; i < motorNames.length; i++) {
|
130 |
+
const motorName = motorNames[i];
|
131 |
+
liveData[motorName] = {
|
132 |
+
current: positions[i],
|
133 |
+
min: rangeMins[motorName],
|
134 |
+
max: rangeMaxes[motorName],
|
135 |
+
range: rangeMaxes[motorName] - rangeMins[motorName],
|
136 |
+
};
|
137 |
}
|
138 |
+
onLiveUpdate(liveData);
|
139 |
}
|
140 |
} catch (error) {
|
141 |
+
// Continue recording despite errors
|
142 |
}
|
143 |
|
144 |
+
// 20fps reading rate for stable Web Serial communication
|
145 |
await new Promise((resolve) => setTimeout(resolve, 50));
|
146 |
}
|
147 |
|
|
|
|
|
148 |
return { rangeMins, rangeMaxes };
|
149 |
}
|
150 |
|
151 |
/**
|
152 |
+
* Create SO-100 web configuration
|
153 |
*/
|
154 |
+
function createSO100WebConfig(
|
155 |
+
deviceType: "so100_follower" | "so100_leader",
|
156 |
+
port: WebSerialPortWrapper
|
157 |
+
): WebCalibrationConfig {
|
158 |
+
const so100Config = createSO100Config(deviceType);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
159 |
|
160 |
+
return {
|
161 |
+
...so100Config,
|
162 |
+
port,
|
163 |
+
};
|
164 |
}
|
165 |
|
166 |
/**
|
167 |
+
* Main calibrate function - minimal library API
|
168 |
+
* Currently supports SO-100 robots (follower and leader)
|
169 |
+
*
|
170 |
+
* Takes a unified RobotConnection object from findPort()
|
171 |
*/
|
172 |
+
export async function calibrate(
|
173 |
+
robotConnection: RobotConnection,
|
174 |
+
options?: {
|
175 |
+
onLiveUpdate?: (data: LiveCalibrationData) => void;
|
176 |
+
onProgress?: (message: string) => void;
|
|
|
|
|
|
|
177 |
}
|
178 |
+
): Promise<CalibrationProcess> {
|
179 |
+
// Validate required fields
|
180 |
+
if (!robotConnection.robotType) {
|
181 |
+
throw new Error(
|
182 |
+
"Robot type is required for calibration. Please configure the robot first."
|
183 |
+
);
|
184 |
}
|
185 |
|
186 |
+
// Create web serial port wrapper
|
187 |
+
const port = new WebSerialPortWrapper(robotConnection.port);
|
188 |
+
await port.initialize();
|
189 |
|
190 |
+
// Get SO-100 specific calibration configuration
|
191 |
+
const config = createSO100WebConfig(robotConnection.robotType, port);
|
192 |
+
|
193 |
+
let shouldStop = false;
|
194 |
+
const stopFunction = () => shouldStop;
|
195 |
+
|
196 |
+
// Start calibration process
|
197 |
+
const resultPromise = (async (): Promise<WebCalibrationResults> => {
|
198 |
+
// Step 1: Set homing offsets (automatic)
|
199 |
+
options?.onProgress?.("⚙️ Setting motor homing offsets");
|
200 |
+
const homingOffsets = await setHomingOffsets(
|
201 |
+
config.port,
|
202 |
+
config.motorIds,
|
203 |
+
config.motorNames
|
204 |
);
|
|
|
|
|
205 |
|
206 |
+
// Step 2: Record ranges of motion with live updates
|
207 |
+
const { rangeMins, rangeMaxes } = await recordRangesOfMotion(
|
208 |
+
config.port,
|
209 |
+
config.motorIds,
|
210 |
+
config.motorNames,
|
211 |
+
stopFunction,
|
212 |
+
options?.onLiveUpdate
|
213 |
+
);
|
214 |
|
215 |
+
// Step 3: Set special range for wrist_roll (full turn motor)
|
216 |
+
// The wrist_roll is a continuous rotation motor that should use the full
|
217 |
+
// 0-4095 range regardless of what the user recorded during calibration.
|
218 |
+
// This matches the hardware specification and Python lerobot behavior.
|
219 |
+
rangeMins["wrist_roll"] = 0;
|
220 |
+
rangeMaxes["wrist_roll"] = 4095;
|
221 |
|
222 |
+
// Step 4: Write hardware position limits to motors
|
223 |
await writeHardwarePositionLimits(
|
224 |
+
config.port,
|
225 |
+
config.motorIds,
|
226 |
+
config.motorNames,
|
227 |
+
rangeMins,
|
228 |
+
rangeMaxes
|
229 |
);
|
230 |
|
231 |
+
// Step 5: Compile results in Python-compatible format
|
232 |
const results: WebCalibrationResults = {};
|
233 |
|
234 |
+
for (let i = 0; i < config.motorNames.length; i++) {
|
235 |
+
const motorName = config.motorNames[i];
|
236 |
+
const motorId = config.motorIds[i];
|
237 |
|
238 |
results[motorName] = {
|
239 |
id: motorId,
|
240 |
+
drive_mode: config.driveModes[i],
|
241 |
+
homing_offset: homingOffsets[motorName],
|
242 |
+
range_min: rangeMins[motorName],
|
243 |
+
range_max: rangeMaxes[motorName],
|
244 |
};
|
245 |
}
|
246 |
|
|
|
247 |
return results;
|
248 |
+
})();
|
|
|
249 |
|
250 |
+
// Return control object
|
|
|
|
|
|
|
|
|
|
|
|
|
251 |
return {
|
252 |
+
stop: () => {
|
253 |
+
shouldStop = true;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
254 |
},
|
255 |
+
result: resultPromise,
|
256 |
};
|
257 |
}
|
258 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
259 |
/**
|
260 |
* Check if Web Serial API is supported
|
261 |
*/
|
src/lerobot/web/find_port.ts
CHANGED
@@ -1,10 +1,35 @@
|
|
1 |
/**
|
2 |
* Browser implementation of find_port using WebSerial API
|
|
|
3 |
*
|
4 |
-
*
|
5 |
-
*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
6 |
*/
|
7 |
|
|
|
|
|
8 |
/**
|
9 |
* Type definitions for WebSerial API (not yet in all TypeScript libs)
|
10 |
*/
|
@@ -49,244 +74,298 @@ declare global {
|
|
49 |
}
|
50 |
|
51 |
/**
|
52 |
-
*
|
|
|
|
|
53 |
*/
|
54 |
-
|
55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
56 |
}
|
57 |
|
58 |
/**
|
59 |
-
*
|
60 |
-
* Browser equivalent of Node.js findAvailablePorts()
|
61 |
*/
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
);
|
67 |
-
}
|
68 |
-
|
69 |
-
try {
|
70 |
-
return await navigator.serial.getPorts();
|
71 |
-
} catch (error) {
|
72 |
-
throw new Error(
|
73 |
-
`Failed to get serial ports: ${
|
74 |
-
error instanceof Error ? error.message : error
|
75 |
-
}`
|
76 |
-
);
|
77 |
-
}
|
78 |
}
|
79 |
|
80 |
/**
|
81 |
-
*
|
82 |
-
* Mimics the Node.js port listing format
|
83 |
*/
|
84 |
-
|
85 |
-
|
86 |
-
|
87 |
-
|
88 |
-
|
89 |
-
|
90 |
-
|
91 |
-
|
|
|
|
|
92 |
}
|
93 |
|
94 |
/**
|
95 |
-
*
|
96 |
-
* Same as Node.js version
|
97 |
*/
|
98 |
-
|
99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
100 |
}
|
101 |
|
102 |
/**
|
103 |
-
*
|
104 |
-
* Browser equivalent of Node.js readline input()
|
105 |
*/
|
106 |
-
function
|
107 |
-
return
|
108 |
-
const modal = document.createElement("div");
|
109 |
-
modal.style.cssText = `
|
110 |
-
position: fixed;
|
111 |
-
top: 0;
|
112 |
-
left: 0;
|
113 |
-
width: 100%;
|
114 |
-
height: 100%;
|
115 |
-
background: rgba(0,0,0,0.5);
|
116 |
-
display: flex;
|
117 |
-
align-items: center;
|
118 |
-
justify-content: center;
|
119 |
-
z-index: 1000;
|
120 |
-
`;
|
121 |
-
|
122 |
-
const dialog = document.createElement("div");
|
123 |
-
dialog.style.cssText = `
|
124 |
-
background: white;
|
125 |
-
padding: 2rem;
|
126 |
-
border-radius: 8px;
|
127 |
-
text-align: center;
|
128 |
-
max-width: 500px;
|
129 |
-
margin: 1rem;
|
130 |
-
`;
|
131 |
-
|
132 |
-
dialog.innerHTML = `
|
133 |
-
<h3>Port Detection</h3>
|
134 |
-
<p style="margin: 1rem 0;">${message}</p>
|
135 |
-
<button id="continue-btn" style="
|
136 |
-
background: #3498db;
|
137 |
-
color: white;
|
138 |
-
border: none;
|
139 |
-
padding: 12px 24px;
|
140 |
-
border-radius: 6px;
|
141 |
-
cursor: pointer;
|
142 |
-
font-size: 1rem;
|
143 |
-
">Continue</button>
|
144 |
-
`;
|
145 |
-
|
146 |
-
modal.appendChild(dialog);
|
147 |
-
document.body.appendChild(modal);
|
148 |
-
|
149 |
-
const continueBtn = dialog.querySelector(
|
150 |
-
"#continue-btn"
|
151 |
-
) as HTMLButtonElement;
|
152 |
-
continueBtn.addEventListener("click", () => {
|
153 |
-
document.body.removeChild(modal);
|
154 |
-
resolve();
|
155 |
-
});
|
156 |
-
});
|
157 |
}
|
158 |
|
159 |
/**
|
160 |
-
*
|
161 |
*/
|
162 |
-
|
163 |
-
|
164 |
-
)
|
165 |
-
|
166 |
-
|
167 |
-
|
168 |
-
|
169 |
|
|
|
|
|
|
|
|
|
170 |
try {
|
171 |
-
|
172 |
-
|
173 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
174 |
} catch (error) {
|
175 |
-
|
176 |
-
|
177 |
}
|
178 |
}
|
179 |
|
180 |
/**
|
181 |
-
*
|
182 |
-
* Maintains identical UX and messaging to Node.js version
|
183 |
*/
|
184 |
-
|
185 |
-
|
186 |
-
): Promise<
|
187 |
-
|
188 |
|
189 |
-
|
190 |
-
if (!isWebSerialSupported()) {
|
191 |
-
throw new Error(
|
192 |
-
"WebSerial API not supported. Please use Chrome/Edge 89+ with HTTPS or localhost."
|
193 |
-
);
|
194 |
-
}
|
195 |
|
196 |
-
// Get initial ports (check what we already have access to)
|
197 |
-
let portsBefore: SerialPort[];
|
198 |
try {
|
199 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
200 |
} catch (error) {
|
|
|
|
|
|
|
|
|
|
|
|
|
201 |
throw new Error(
|
202 |
-
`Failed to
|
203 |
-
error instanceof Error ? error.message : error
|
204 |
-
}`
|
205 |
);
|
206 |
}
|
|
|
207 |
|
208 |
-
|
209 |
-
|
210 |
-
|
211 |
-
|
212 |
-
|
213 |
-
|
214 |
-
|
215 |
-
|
216 |
-
|
217 |
-
|
218 |
-
|
219 |
-
|
220 |
-
|
221 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
222 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
223 |
}
|
224 |
|
225 |
-
|
226 |
-
|
227 |
-
|
228 |
-
`Ports before disconnecting: [${portsBeforeFormatted
|
229 |
-
.map((p) => `'${p}'`)
|
230 |
-
.join(", ")}]`
|
231 |
);
|
232 |
|
233 |
-
|
234 |
-
|
235 |
-
"Remove the USB cable from your MotorsBus and press Continue when done."
|
236 |
-
);
|
237 |
-
await waitForUserAction(
|
238 |
-
"Remove the USB cable from your MotorsBus and press Continue when done."
|
239 |
-
);
|
240 |
|
241 |
-
|
242 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
243 |
|
244 |
-
|
245 |
-
|
246 |
-
|
247 |
-
|
248 |
-
`Ports after disconnecting: [${portsAfterFormatted
|
249 |
-
.map((p) => `'${p}'`)
|
250 |
-
.join(", ")}]`
|
251 |
);
|
252 |
|
253 |
-
//
|
254 |
-
|
255 |
-
|
256 |
-
|
257 |
-
});
|
258 |
-
|
259 |
-
// If object comparison fails (e.g., browser creates new objects), fall back to count-based detection
|
260 |
-
if (removedPorts.length === 0 && portsBefore.length > portsAfter.length) {
|
261 |
-
const countDifference = portsBefore.length - portsAfter.length;
|
262 |
-
if (countDifference === 1) {
|
263 |
-
logger(`The port of this MotorsBus is one of the disconnected devices.`);
|
264 |
-
logger(
|
265 |
-
"Note: Exact port identification not possible with identical devices."
|
266 |
-
);
|
267 |
-
logger("Reconnect the USB cable.");
|
268 |
-
return;
|
269 |
-
} else {
|
270 |
-
logger(`${countDifference} ports were removed, but expected exactly 1.`);
|
271 |
-
logger("Please disconnect only one device and try again.");
|
272 |
-
return;
|
273 |
}
|
274 |
-
}
|
275 |
|
276 |
-
|
277 |
-
|
278 |
-
|
279 |
-
|
280 |
-
|
281 |
-
|
282 |
-
|
283 |
-
|
284 |
-
|
285 |
-
|
286 |
-
|
287 |
-
|
288 |
-
|
289 |
-
|
290 |
-
|
291 |
-
}
|
292 |
}
|
|
|
|
|
|
|
|
1 |
/**
|
2 |
* Browser implementation of find_port using WebSerial API
|
3 |
+
* Clean API with native dialogs and auto-connect modes
|
4 |
*
|
5 |
+
* Usage Examples:
|
6 |
+
*
|
7 |
+
* // Interactive mode - always returns array
|
8 |
+
* const findProcess = await findPort();
|
9 |
+
* const robotConnections = await findProcess.result;
|
10 |
+
* const robot = robotConnections[0]; // First (and only) robot
|
11 |
+
* await calibrate(robot, options);
|
12 |
+
*
|
13 |
+
* // Auto-connect mode - returns array of all attempted connections
|
14 |
+
* const findProcess = await findPort({
|
15 |
+
* robotConfigs: [
|
16 |
+
* { robotType: "so100_follower", robotId: "arm1", serialNumber: "ABC123" },
|
17 |
+
* { robotType: "so100_leader", robotId: "arm2", serialNumber: "DEF456" }
|
18 |
+
* ]
|
19 |
+
* });
|
20 |
+
* const robotConnections = await findProcess.result;
|
21 |
+
* for (const robot of robotConnections.filter(r => r.isConnected)) {
|
22 |
+
* await calibrate(robot, options);
|
23 |
+
* }
|
24 |
+
*
|
25 |
+
* // Store/load from localStorage
|
26 |
+
* localStorage.setItem('myRobots', JSON.stringify(robotConnections));
|
27 |
+
* const storedRobots = JSON.parse(localStorage.getItem('myRobots'));
|
28 |
+
* await calibrate(storedRobots[0], options);
|
29 |
*/
|
30 |
|
31 |
+
import { getRobotConnectionManager } from "./robot-connection.js";
|
32 |
+
|
33 |
/**
|
34 |
* Type definitions for WebSerial API (not yet in all TypeScript libs)
|
35 |
*/
|
|
|
74 |
}
|
75 |
|
76 |
/**
|
77 |
+
* Unified robot connection interface used across all functions
|
78 |
+
* This same object works for findPort, calibrate, teleoperate, etc.
|
79 |
+
* Includes all fields needed by demo and other applications
|
80 |
*/
|
81 |
+
export interface RobotConnection {
|
82 |
+
port: SerialPort;
|
83 |
+
name: string; // Display name for UI
|
84 |
+
isConnected: boolean; // Connection status
|
85 |
+
robotType?: "so100_follower" | "so100_leader"; // Optional until user configures
|
86 |
+
robotId?: string; // Optional until user configures
|
87 |
+
serialNumber: string; // Always required for identification
|
88 |
+
error?: string; // Error message if connection failed
|
89 |
+
usbMetadata?: {
|
90 |
+
// USB device information
|
91 |
+
vendorId: string;
|
92 |
+
productId: string;
|
93 |
+
serialNumber: string;
|
94 |
+
manufacturerName: string;
|
95 |
+
productName: string;
|
96 |
+
usbVersionMajor?: number;
|
97 |
+
usbVersionMinor?: number;
|
98 |
+
deviceClass?: number;
|
99 |
+
deviceSubclass?: number;
|
100 |
+
deviceProtocol?: number;
|
101 |
+
};
|
102 |
}
|
103 |
|
104 |
/**
|
105 |
+
* Minimal robot config for finding/connecting to specific robots
|
|
|
106 |
*/
|
107 |
+
export interface RobotConfig {
|
108 |
+
robotType: "so100_follower" | "so100_leader";
|
109 |
+
robotId: string;
|
110 |
+
serialNumber: string;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
111 |
}
|
112 |
|
113 |
/**
|
114 |
+
* Options for findPort function
|
|
|
115 |
*/
|
116 |
+
export interface FindPortOptions {
|
117 |
+
// Auto-connect mode: provide robot configs to connect to
|
118 |
+
robotConfigs?: RobotConfig[];
|
119 |
+
|
120 |
+
// Callbacks
|
121 |
+
onMessage?: (message: string) => void;
|
122 |
+
onRequestUserAction?: (
|
123 |
+
message: string,
|
124 |
+
type: "confirm" | "select"
|
125 |
+
) => Promise<boolean>;
|
126 |
}
|
127 |
|
128 |
/**
|
129 |
+
* Process object returned by findPort
|
|
|
130 |
*/
|
131 |
+
export interface FindPortProcess {
|
132 |
+
// Result promise - Always returns RobotConnection[] (consistent API)
|
133 |
+
// Interactive mode: single robot in array
|
134 |
+
// Auto-connect mode: all successfully connected robots in array
|
135 |
+
result: Promise<RobotConnection[]>;
|
136 |
+
|
137 |
+
// Control
|
138 |
+
stop: () => void;
|
139 |
}
|
140 |
|
141 |
/**
|
142 |
+
* Check if WebSerial API is available
|
|
|
143 |
*/
|
144 |
+
function isWebSerialSupported(): boolean {
|
145 |
+
return "serial" in navigator;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
146 |
}
|
147 |
|
148 |
/**
|
149 |
+
* Get display name for a port
|
150 |
*/
|
151 |
+
function getPortDisplayName(port: SerialPort): string {
|
152 |
+
const info = port.getInfo();
|
153 |
+
if (info.usbVendorId && info.usbProductId) {
|
154 |
+
return `USB Device (${info.usbVendorId}:${info.usbProductId})`;
|
155 |
+
}
|
156 |
+
return "Serial Device";
|
157 |
+
}
|
158 |
|
159 |
+
/**
|
160 |
+
* Try to get serial number from a port by opening and reading device info
|
161 |
+
*/
|
162 |
+
async function getPortSerialNumber(port: SerialPort): Promise<string | null> {
|
163 |
try {
|
164 |
+
const wasOpen = port.readable !== null;
|
165 |
+
|
166 |
+
// Open port if not already open
|
167 |
+
if (!wasOpen) {
|
168 |
+
await port.open({ baudRate: 1000000 });
|
169 |
+
}
|
170 |
+
|
171 |
+
// For now, we'll return null since reading serial number from STS3215 motors
|
172 |
+
// requires specific protocol implementation. This is a placeholder for future enhancement.
|
173 |
+
// In practice, serial numbers are typically stored in device metadata or configuration.
|
174 |
+
|
175 |
+
// Close port if we opened it
|
176 |
+
if (!wasOpen && port.readable) {
|
177 |
+
await port.close();
|
178 |
+
}
|
179 |
+
|
180 |
+
return null;
|
181 |
} catch (error) {
|
182 |
+
console.warn("Could not read serial number from port:", error);
|
183 |
+
return null;
|
184 |
}
|
185 |
}
|
186 |
|
187 |
/**
|
188 |
+
* Interactive mode: Show native dialog for port selection
|
|
|
189 |
*/
|
190 |
+
async function findPortInteractive(
|
191 |
+
options: FindPortOptions
|
192 |
+
): Promise<RobotConnection[]> {
|
193 |
+
const { onMessage } = options;
|
194 |
|
195 |
+
onMessage?.("Opening port selection dialog...");
|
|
|
|
|
|
|
|
|
|
|
196 |
|
|
|
|
|
197 |
try {
|
198 |
+
// Use native browser dialog - much better UX than port diffing!
|
199 |
+
const port = await navigator.serial.requestPort();
|
200 |
+
|
201 |
+
// Open the port
|
202 |
+
await port.open({ baudRate: 1000000 });
|
203 |
+
|
204 |
+
const portName = getPortDisplayName(port);
|
205 |
+
onMessage?.(`✅ Connected to ${portName}`);
|
206 |
+
|
207 |
+
// Return unified RobotConnection object in array (consistent API)
|
208 |
+
// In interactive mode, user will need to specify robot details separately
|
209 |
+
return [
|
210 |
+
{
|
211 |
+
port,
|
212 |
+
name: portName,
|
213 |
+
isConnected: true,
|
214 |
+
robotType: "so100_follower", // Default, user can change
|
215 |
+
robotId: "interactive_robot",
|
216 |
+
serialNumber: `interactive_${Date.now()}`,
|
217 |
+
},
|
218 |
+
];
|
219 |
} catch (error) {
|
220 |
+
if (
|
221 |
+
error instanceof Error &&
|
222 |
+
(error.message.includes("cancelled") || error.name === "NotAllowedError")
|
223 |
+
) {
|
224 |
+
throw new Error("Port selection cancelled by user");
|
225 |
+
}
|
226 |
throw new Error(
|
227 |
+
`Failed to select port: ${error instanceof Error ? error.message : error}`
|
|
|
|
|
228 |
);
|
229 |
}
|
230 |
+
}
|
231 |
|
232 |
+
/**
|
233 |
+
* Auto-connect mode: Connect to robots by serial number
|
234 |
+
* Returns all successfully connected robots
|
235 |
+
*/
|
236 |
+
async function findPortAutoConnect(
|
237 |
+
robotConfigs: RobotConfig[],
|
238 |
+
options: FindPortOptions
|
239 |
+
): Promise<RobotConnection[]> {
|
240 |
+
const { onMessage } = options;
|
241 |
+
const results: RobotConnection[] = [];
|
242 |
+
|
243 |
+
onMessage?.(`🔍 Auto-connecting to ${robotConfigs.length} robot(s)...`);
|
244 |
+
|
245 |
+
// Get all available ports
|
246 |
+
const availablePorts = await navigator.serial.getPorts();
|
247 |
+
onMessage?.(`Found ${availablePorts.length} available port(s)`);
|
248 |
+
|
249 |
+
for (const config of robotConfigs) {
|
250 |
+
onMessage?.(`Connecting to ${config.robotId} (${config.serialNumber})...`);
|
251 |
+
|
252 |
+
let connected = false;
|
253 |
+
let matchedPort: SerialPort | null = null;
|
254 |
+
let error: string | undefined;
|
255 |
+
|
256 |
+
try {
|
257 |
+
// For now, we'll try each available port and see if we can connect
|
258 |
+
// In a future enhancement, we could match by actual serial number reading
|
259 |
+
for (const port of availablePorts) {
|
260 |
+
try {
|
261 |
+
// Try to open and use this port
|
262 |
+
const wasOpen = port.readable !== null;
|
263 |
+
if (!wasOpen) {
|
264 |
+
await port.open({ baudRate: 1000000 });
|
265 |
+
}
|
266 |
+
|
267 |
+
// Test connection by trying to communicate
|
268 |
+
const manager = getRobotConnectionManager();
|
269 |
+
await manager.connect(
|
270 |
+
port,
|
271 |
+
config.robotType,
|
272 |
+
config.robotId,
|
273 |
+
config.serialNumber
|
274 |
+
);
|
275 |
+
|
276 |
+
matchedPort = port;
|
277 |
+
connected = true;
|
278 |
+
onMessage?.(`✅ Connected to ${config.robotId}`);
|
279 |
+
break;
|
280 |
+
} catch (portError) {
|
281 |
+
// This port didn't work, try next one
|
282 |
+
console.log(
|
283 |
+
`Port ${getPortDisplayName(port)} didn't match ${config.robotId}:`,
|
284 |
+
portError
|
285 |
+
);
|
286 |
+
continue;
|
287 |
+
}
|
288 |
+
}
|
289 |
+
|
290 |
+
if (!connected) {
|
291 |
+
error = `No matching port found for ${config.robotId} (${config.serialNumber})`;
|
292 |
+
onMessage?.(`❌ ${error}`);
|
293 |
+
}
|
294 |
+
} catch (err) {
|
295 |
+
error = err instanceof Error ? err.message : "Unknown error";
|
296 |
+
onMessage?.(`❌ Failed to connect to ${config.robotId}: ${error}`);
|
297 |
}
|
298 |
+
|
299 |
+
// Add result (successful or failed)
|
300 |
+
results.push({
|
301 |
+
port: matchedPort!,
|
302 |
+
name: matchedPort ? getPortDisplayName(matchedPort) : "Unknown Port",
|
303 |
+
isConnected: connected,
|
304 |
+
robotType: config.robotType,
|
305 |
+
robotId: config.robotId,
|
306 |
+
serialNumber: config.serialNumber,
|
307 |
+
error,
|
308 |
+
});
|
309 |
}
|
310 |
|
311 |
+
const successCount = results.filter((r) => r.isConnected).length;
|
312 |
+
onMessage?.(
|
313 |
+
`🎯 Connected to ${successCount}/${robotConfigs.length} robot(s)`
|
|
|
|
|
|
|
314 |
);
|
315 |
|
316 |
+
return results;
|
317 |
+
}
|
|
|
|
|
|
|
|
|
|
|
318 |
|
319 |
+
/**
|
320 |
+
* Main findPort function - clean API with Two modes:
|
321 |
+
*
|
322 |
+
* Mode 1: Interactive - Returns single RobotConnection
|
323 |
+
* Mode 2: Auto-connect - Returns RobotConnection[]
|
324 |
+
*/
|
325 |
+
export async function findPort(
|
326 |
+
options: FindPortOptions = {}
|
327 |
+
): Promise<FindPortProcess> {
|
328 |
+
// Check WebSerial support
|
329 |
+
if (!isWebSerialSupported()) {
|
330 |
+
throw new Error(
|
331 |
+
"WebSerial API not supported. Please use Chrome/Edge 89+ with HTTPS or localhost."
|
332 |
+
);
|
333 |
+
}
|
334 |
+
|
335 |
+
const { robotConfigs, onMessage } = options;
|
336 |
+
let stopped = false;
|
337 |
+
|
338 |
+
// Determine mode
|
339 |
+
const isAutoConnectMode = robotConfigs && robotConfigs.length > 0;
|
340 |
|
341 |
+
onMessage?.(
|
342 |
+
`🤖 ${
|
343 |
+
isAutoConnectMode ? "Auto-connect" : "Interactive"
|
344 |
+
} port discovery started`
|
|
|
|
|
|
|
345 |
);
|
346 |
|
347 |
+
// Create result promise
|
348 |
+
const resultPromise = (async () => {
|
349 |
+
if (stopped) {
|
350 |
+
throw new Error("Port discovery was stopped");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
351 |
}
|
|
|
352 |
|
353 |
+
if (isAutoConnectMode) {
|
354 |
+
return await findPortAutoConnect(robotConfigs!, options);
|
355 |
+
} else {
|
356 |
+
return await findPortInteractive(options);
|
357 |
+
}
|
358 |
+
})();
|
359 |
+
|
360 |
+
// Return process object
|
361 |
+
return {
|
362 |
+
result: resultPromise,
|
363 |
+
stop: () => {
|
364 |
+
stopped = true;
|
365 |
+
onMessage?.("🛑 Port discovery stopped");
|
366 |
+
},
|
367 |
+
};
|
|
|
368 |
}
|
369 |
+
|
370 |
+
// Export the main function (renamed from findPortWeb)
|
371 |
+
export { findPort as findPortWeb }; // Backward compatibility alias
|
src/lerobot/web/robot-connection.ts
CHANGED
@@ -10,7 +10,7 @@ import {
|
|
10 |
readAllMotorPositions as readAllMotorPositionsUtil,
|
11 |
writeMotorRegister,
|
12 |
type MotorCommunicationPort,
|
13 |
-
} from "./motor-
|
14 |
|
15 |
export interface RobotConnectionState {
|
16 |
isConnected: boolean;
|
|
|
10 |
readAllMotorPositions as readAllMotorPositionsUtil,
|
11 |
writeMotorRegister,
|
12 |
type MotorCommunicationPort,
|
13 |
+
} from "./utils/motor-communication.js";
|
14 |
|
15 |
export interface RobotConnectionState {
|
16 |
isConnected: boolean;
|
src/lerobot/web/robots/so100_config.ts
ADDED
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* SO-100 specific configuration for web calibration
|
3 |
+
* Matches Node.js SO-100 config structure and Python lerobot exactly
|
4 |
+
*/
|
5 |
+
|
6 |
+
/**
|
7 |
+
* STS3215 Protocol Configuration for SO-100 devices
|
8 |
+
*/
|
9 |
+
export const WEB_STS3215_PROTOCOL = {
|
10 |
+
resolution: 4096, // 12-bit resolution (0-4095)
|
11 |
+
homingOffsetAddress: 31, // Address for Homing_Offset register
|
12 |
+
homingOffsetLength: 2, // 2 bytes for Homing_Offset
|
13 |
+
presentPositionAddress: 56, // Address for Present_Position register
|
14 |
+
presentPositionLength: 2, // 2 bytes for Present_Position
|
15 |
+
minPositionLimitAddress: 9, // Address for Min_Position_Limit register
|
16 |
+
minPositionLimitLength: 2, // 2 bytes for Min_Position_Limit
|
17 |
+
maxPositionLimitAddress: 11, // Address for Max_Position_Limit register
|
18 |
+
maxPositionLimitLength: 2, // 2 bytes for Max_Position_Limit
|
19 |
+
signMagnitudeBit: 11, // Bit 11 is sign bit for Homing_Offset encoding
|
20 |
+
} as const;
|
21 |
+
|
22 |
+
/**
|
23 |
+
* SO-100 Device Configuration
|
24 |
+
* Motor names, IDs, and drive modes for both follower and leader
|
25 |
+
*/
|
26 |
+
export const SO100_CONFIG = {
|
27 |
+
motorNames: [
|
28 |
+
"shoulder_pan",
|
29 |
+
"shoulder_lift",
|
30 |
+
"elbow_flex",
|
31 |
+
"wrist_flex",
|
32 |
+
"wrist_roll",
|
33 |
+
"gripper",
|
34 |
+
],
|
35 |
+
motorIds: [1, 2, 3, 4, 5, 6],
|
36 |
+
// Python lerobot uses drive_mode=0 for all SO-100 motors
|
37 |
+
driveModes: [0, 0, 0, 0, 0, 0],
|
38 |
+
};
|
39 |
+
|
40 |
+
/**
|
41 |
+
* Create SO-100 calibration configuration
|
42 |
+
*/
|
43 |
+
export function createSO100Config(
|
44 |
+
deviceType: "so100_follower" | "so100_leader"
|
45 |
+
) {
|
46 |
+
return {
|
47 |
+
deviceType,
|
48 |
+
motorNames: SO100_CONFIG.motorNames,
|
49 |
+
motorIds: SO100_CONFIG.motorIds,
|
50 |
+
driveModes: SO100_CONFIG.driveModes,
|
51 |
+
protocol: WEB_STS3215_PROTOCOL,
|
52 |
+
};
|
53 |
+
}
|
src/lerobot/web/teleoperate.ts
CHANGED
@@ -58,7 +58,8 @@ export const KEYBOARD_CONTROLS = {
|
|
58 |
// Wrist roll and gripper
|
59 |
q: { motor: "wrist_roll", direction: -1, description: "Wrist roll left" },
|
60 |
e: { motor: "wrist_roll", direction: 1, description: "Wrist roll right" },
|
61 |
-
|
|
|
62 |
|
63 |
// Emergency stop
|
64 |
Escape: {
|
|
|
58 |
// Wrist roll and gripper
|
59 |
q: { motor: "wrist_roll", direction: -1, description: "Wrist roll left" },
|
60 |
e: { motor: "wrist_roll", direction: 1, description: "Wrist roll right" },
|
61 |
+
o: { motor: "gripper", direction: 1, description: "Gripper open" },
|
62 |
+
c: { motor: "gripper", direction: -1, description: "Gripper close" },
|
63 |
|
64 |
// Emergency stop
|
65 |
Escape: {
|
src/lerobot/web/utils/motor-calibration.ts
ADDED
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Motor Calibration Utilities
|
3 |
+
* Specialized functions for motor calibration procedures
|
4 |
+
*/
|
5 |
+
|
6 |
+
import { STS3215_PROTOCOL } from "./sts3215-protocol.js";
|
7 |
+
import { encodeSignMagnitude } from "./sign-magnitude.js";
|
8 |
+
import {
|
9 |
+
readAllMotorPositions,
|
10 |
+
writeMotorRegister,
|
11 |
+
type MotorCommunicationPort,
|
12 |
+
} from "./motor-communication.js";
|
13 |
+
|
14 |
+
/**
|
15 |
+
* Reset homing offsets to 0 for all motors (matches Node.js exactly)
|
16 |
+
*/
|
17 |
+
export async function resetHomingOffsets(
|
18 |
+
port: MotorCommunicationPort,
|
19 |
+
motorIds: number[]
|
20 |
+
): Promise<void> {
|
21 |
+
for (let i = 0; i < motorIds.length; i++) {
|
22 |
+
const motorId = motorIds[i];
|
23 |
+
|
24 |
+
try {
|
25 |
+
const packet = new Uint8Array([
|
26 |
+
0xff,
|
27 |
+
0xff,
|
28 |
+
motorId,
|
29 |
+
0x05,
|
30 |
+
0x03,
|
31 |
+
STS3215_PROTOCOL.HOMING_OFFSET_ADDRESS,
|
32 |
+
0x00, // Low byte of 0
|
33 |
+
0x00, // High byte of 0
|
34 |
+
0x00, // Checksum
|
35 |
+
]);
|
36 |
+
|
37 |
+
const checksum =
|
38 |
+
~(
|
39 |
+
motorId +
|
40 |
+
0x05 +
|
41 |
+
0x03 +
|
42 |
+
STS3215_PROTOCOL.HOMING_OFFSET_ADDRESS +
|
43 |
+
0x00 +
|
44 |
+
0x00
|
45 |
+
) & 0xff;
|
46 |
+
packet[8] = checksum;
|
47 |
+
|
48 |
+
await port.write(packet);
|
49 |
+
|
50 |
+
try {
|
51 |
+
await port.read(200);
|
52 |
+
} catch (error) {
|
53 |
+
// Silent - response not required
|
54 |
+
}
|
55 |
+
} catch (error) {
|
56 |
+
throw new Error(`Failed to reset homing offset for motor ${motorId}`);
|
57 |
+
}
|
58 |
+
}
|
59 |
+
}
|
60 |
+
|
61 |
+
/**
|
62 |
+
* Write homing offsets to motor registers immediately
|
63 |
+
*/
|
64 |
+
export async function writeHomingOffsetsToMotors(
|
65 |
+
port: MotorCommunicationPort,
|
66 |
+
motorIds: number[],
|
67 |
+
motorNames: string[],
|
68 |
+
homingOffsets: { [motor: string]: number }
|
69 |
+
): Promise<void> {
|
70 |
+
for (let i = 0; i < motorIds.length; i++) {
|
71 |
+
const motorId = motorIds[i];
|
72 |
+
const motorName = motorNames[i];
|
73 |
+
const homingOffset = homingOffsets[motorName];
|
74 |
+
|
75 |
+
try {
|
76 |
+
const encodedOffset = encodeSignMagnitude(
|
77 |
+
homingOffset,
|
78 |
+
STS3215_PROTOCOL.SIGN_MAGNITUDE_BIT
|
79 |
+
);
|
80 |
+
|
81 |
+
const packet = new Uint8Array([
|
82 |
+
0xff,
|
83 |
+
0xff,
|
84 |
+
motorId,
|
85 |
+
0x05,
|
86 |
+
0x03,
|
87 |
+
STS3215_PROTOCOL.HOMING_OFFSET_ADDRESS,
|
88 |
+
encodedOffset & 0xff,
|
89 |
+
(encodedOffset >> 8) & 0xff,
|
90 |
+
0x00,
|
91 |
+
]);
|
92 |
+
|
93 |
+
const checksum =
|
94 |
+
~(
|
95 |
+
motorId +
|
96 |
+
0x05 +
|
97 |
+
0x03 +
|
98 |
+
STS3215_PROTOCOL.HOMING_OFFSET_ADDRESS +
|
99 |
+
(encodedOffset & 0xff) +
|
100 |
+
((encodedOffset >> 8) & 0xff)
|
101 |
+
) & 0xff;
|
102 |
+
packet[8] = checksum;
|
103 |
+
|
104 |
+
await port.write(packet);
|
105 |
+
|
106 |
+
try {
|
107 |
+
await port.read(200);
|
108 |
+
} catch (error) {
|
109 |
+
// Silent - response not required
|
110 |
+
}
|
111 |
+
} catch (error) {
|
112 |
+
throw new Error(`Failed to write homing offset for ${motorName}`);
|
113 |
+
}
|
114 |
+
}
|
115 |
+
}
|
116 |
+
|
117 |
+
/**
|
118 |
+
* Set homing offsets with immediate writing (matches Node.js exactly)
|
119 |
+
*/
|
120 |
+
export async function setHomingOffsets(
|
121 |
+
port: MotorCommunicationPort,
|
122 |
+
motorIds: number[],
|
123 |
+
motorNames: string[]
|
124 |
+
): Promise<{ [motor: string]: number }> {
|
125 |
+
// Reset existing homing offsets to 0 first
|
126 |
+
await resetHomingOffsets(port, motorIds);
|
127 |
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
128 |
+
|
129 |
+
// Read positions (which will be true physical positions)
|
130 |
+
const currentPositions = await readAllMotorPositions(port, motorIds);
|
131 |
+
const homingOffsets: { [motor: string]: number } = {};
|
132 |
+
|
133 |
+
const halfTurn = Math.floor((STS3215_PROTOCOL.RESOLUTION - 1) / 2);
|
134 |
+
|
135 |
+
for (let i = 0; i < motorNames.length; i++) {
|
136 |
+
const motorName = motorNames[i];
|
137 |
+
const position = currentPositions[i];
|
138 |
+
homingOffsets[motorName] = position - halfTurn;
|
139 |
+
}
|
140 |
+
|
141 |
+
// Write homing offsets to motors immediately
|
142 |
+
await writeHomingOffsetsToMotors(port, motorIds, motorNames, homingOffsets);
|
143 |
+
|
144 |
+
return homingOffsets;
|
145 |
+
}
|
146 |
+
|
147 |
+
/**
|
148 |
+
* Write hardware position limits to motors
|
149 |
+
*/
|
150 |
+
export async function writeHardwarePositionLimits(
|
151 |
+
port: MotorCommunicationPort,
|
152 |
+
motorIds: number[],
|
153 |
+
motorNames: string[],
|
154 |
+
rangeMins: { [motor: string]: number },
|
155 |
+
rangeMaxes: { [motor: string]: number }
|
156 |
+
): Promise<void> {
|
157 |
+
for (let i = 0; i < motorIds.length; i++) {
|
158 |
+
const motorId = motorIds[i];
|
159 |
+
const motorName = motorNames[i];
|
160 |
+
const minLimit = rangeMins[motorName];
|
161 |
+
const maxLimit = rangeMaxes[motorName];
|
162 |
+
|
163 |
+
try {
|
164 |
+
// Write Min_Position_Limit register
|
165 |
+
await writeMotorRegister(
|
166 |
+
port,
|
167 |
+
motorId,
|
168 |
+
STS3215_PROTOCOL.MIN_POSITION_LIMIT_ADDRESS,
|
169 |
+
minLimit
|
170 |
+
);
|
171 |
+
|
172 |
+
// Write Max_Position_Limit register
|
173 |
+
await writeMotorRegister(
|
174 |
+
port,
|
175 |
+
motorId,
|
176 |
+
STS3215_PROTOCOL.MAX_POSITION_LIMIT_ADDRESS,
|
177 |
+
maxLimit
|
178 |
+
);
|
179 |
+
} catch (error) {
|
180 |
+
throw new Error(`Failed to write position limits for ${motorName}`);
|
181 |
+
}
|
182 |
+
}
|
183 |
+
}
|
src/lerobot/web/{motor-utils.ts → utils/motor-communication.ts}
RENAMED
@@ -1,40 +1,22 @@
|
|
1 |
/**
|
2 |
-
*
|
3 |
-
* Proven patterns
|
4 |
-
* Used by both calibration and teleoperation
|
5 |
*/
|
6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
7 |
export interface MotorCommunicationPort {
|
8 |
write(data: Uint8Array): Promise<void>;
|
9 |
read(timeout?: number): Promise<Uint8Array>;
|
10 |
}
|
11 |
|
12 |
-
/**
|
13 |
-
* STS3215 Protocol Constants
|
14 |
-
* Single source of truth for all motor communication
|
15 |
-
*/
|
16 |
-
export const STS3215_PROTOCOL = {
|
17 |
-
// Register addresses
|
18 |
-
PRESENT_POSITION_ADDRESS: 56,
|
19 |
-
GOAL_POSITION_ADDRESS: 42,
|
20 |
-
HOMING_OFFSET_ADDRESS: 31,
|
21 |
-
MIN_POSITION_LIMIT_ADDRESS: 9,
|
22 |
-
MAX_POSITION_LIMIT_ADDRESS: 11,
|
23 |
-
|
24 |
-
// Protocol constants
|
25 |
-
RESOLUTION: 4096, // 12-bit resolution (0-4095)
|
26 |
-
SIGN_MAGNITUDE_BIT: 11, // Bit 11 is sign bit for Homing_Offset encoding
|
27 |
-
|
28 |
-
// Communication timing (proven from calibration)
|
29 |
-
WRITE_TO_READ_DELAY: 10,
|
30 |
-
RETRY_DELAY: 20,
|
31 |
-
INTER_MOTOR_DELAY: 10,
|
32 |
-
MAX_RETRIES: 3,
|
33 |
-
} as const;
|
34 |
-
|
35 |
/**
|
36 |
* Read single motor position with PROVEN retry logic
|
37 |
-
*
|
38 |
*/
|
39 |
export async function readMotorPosition(
|
40 |
port: MotorCommunicationPort,
|
@@ -94,13 +76,7 @@ export async function readMotorPosition(
|
|
94 |
if (id === motorId && error === 0) {
|
95 |
const position = response[5] | (response[6] << 8);
|
96 |
return position;
|
97 |
-
} else if (id === motorId && error !== 0) {
|
98 |
-
// Motor error, retry
|
99 |
-
} else {
|
100 |
-
// Wrong response ID, retry
|
101 |
}
|
102 |
-
} else {
|
103 |
-
// Short response, retry
|
104 |
}
|
105 |
} catch (readError) {
|
106 |
// Read timeout, retry
|
@@ -117,7 +93,6 @@ export async function readMotorPosition(
|
|
117 |
// If all attempts failed, return null
|
118 |
return null;
|
119 |
} catch (error) {
|
120 |
-
console.warn(`Failed to read motor ${motorId} position:`, error);
|
121 |
return null;
|
122 |
}
|
123 |
}
|
@@ -155,7 +130,7 @@ export async function readAllMotorPositions(
|
|
155 |
}
|
156 |
|
157 |
/**
|
158 |
-
* Write motor position
|
159 |
*/
|
160 |
export async function writeMotorPosition(
|
161 |
port: MotorCommunicationPort,
|
@@ -237,30 +212,71 @@ export async function writeMotorRegister(
|
|
237 |
}
|
238 |
|
239 |
/**
|
240 |
-
*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
241 |
*/
|
242 |
-
export function
|
243 |
-
|
244 |
-
|
245 |
-
):
|
246 |
-
|
247 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
248 |
|
249 |
-
|
250 |
-
|
251 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
252 |
);
|
253 |
}
|
254 |
-
|
255 |
-
const directionBit = value < 0 ? 1 : 0;
|
256 |
-
return (directionBit << signBitIndex) | magnitude;
|
257 |
}
|
258 |
|
259 |
-
|
260 |
-
|
261 |
-
|
262 |
-
|
263 |
-
|
264 |
-
|
265 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
266 |
}
|
|
|
1 |
/**
|
2 |
+
* Motor Communication Utilities
|
3 |
+
* Proven patterns for STS3215 motor reading and writing operations
|
|
|
4 |
*/
|
5 |
|
6 |
+
import { STS3215_PROTOCOL } from "./sts3215-protocol.js";
|
7 |
+
|
8 |
+
/**
|
9 |
+
* Interface for motor communication port
|
10 |
+
* Compatible with both WebSerialPortWrapper and RobotConnectionManager
|
11 |
+
*/
|
12 |
export interface MotorCommunicationPort {
|
13 |
write(data: Uint8Array): Promise<void>;
|
14 |
read(timeout?: number): Promise<Uint8Array>;
|
15 |
}
|
16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
17 |
/**
|
18 |
* Read single motor position with PROVEN retry logic
|
19 |
+
* Extracted from calibrate.ts with all proven timing and retry patterns
|
20 |
*/
|
21 |
export async function readMotorPosition(
|
22 |
port: MotorCommunicationPort,
|
|
|
76 |
if (id === motorId && error === 0) {
|
77 |
const position = response[5] | (response[6] << 8);
|
78 |
return position;
|
|
|
|
|
|
|
|
|
79 |
}
|
|
|
|
|
80 |
}
|
81 |
} catch (readError) {
|
82 |
// Read timeout, retry
|
|
|
93 |
// If all attempts failed, return null
|
94 |
return null;
|
95 |
} catch (error) {
|
|
|
96 |
return null;
|
97 |
}
|
98 |
}
|
|
|
130 |
}
|
131 |
|
132 |
/**
|
133 |
+
* Write motor goal position
|
134 |
*/
|
135 |
export async function writeMotorPosition(
|
136 |
port: MotorCommunicationPort,
|
|
|
212 |
}
|
213 |
|
214 |
/**
|
215 |
+
* Lock a motor (motor will hold its position and resist movement)
|
216 |
+
*/
|
217 |
+
export async function lockMotor(
|
218 |
+
port: MotorCommunicationPort,
|
219 |
+
motorId: number
|
220 |
+
): Promise<void> {
|
221 |
+
await writeMotorRegister(
|
222 |
+
port,
|
223 |
+
motorId,
|
224 |
+
STS3215_PROTOCOL.TORQUE_ENABLE_ADDRESS,
|
225 |
+
1
|
226 |
+
);
|
227 |
+
// Small delay for command processing
|
228 |
+
await new Promise((resolve) =>
|
229 |
+
setTimeout(resolve, STS3215_PROTOCOL.WRITE_TO_READ_DELAY)
|
230 |
+
);
|
231 |
+
}
|
232 |
+
|
233 |
+
/**
|
234 |
+
* Release a motor (motor can be moved freely by hand)
|
235 |
*/
|
236 |
+
export async function releaseMotor(
|
237 |
+
port: MotorCommunicationPort,
|
238 |
+
motorId: number
|
239 |
+
): Promise<void> {
|
240 |
+
await writeMotorRegister(
|
241 |
+
port,
|
242 |
+
motorId,
|
243 |
+
STS3215_PROTOCOL.TORQUE_ENABLE_ADDRESS,
|
244 |
+
0
|
245 |
+
);
|
246 |
+
// Small delay for command processing
|
247 |
+
await new Promise((resolve) =>
|
248 |
+
setTimeout(resolve, STS3215_PROTOCOL.WRITE_TO_READ_DELAY)
|
249 |
+
);
|
250 |
+
}
|
251 |
|
252 |
+
/**
|
253 |
+
* Release motors (motors can be moved freely - perfect for calibration)
|
254 |
+
*/
|
255 |
+
export async function releaseMotors(
|
256 |
+
port: MotorCommunicationPort,
|
257 |
+
motorIds: number[]
|
258 |
+
): Promise<void> {
|
259 |
+
for (const motorId of motorIds) {
|
260 |
+
await releaseMotor(port, motorId);
|
261 |
+
// Small delay between motors
|
262 |
+
await new Promise((resolve) =>
|
263 |
+
setTimeout(resolve, STS3215_PROTOCOL.INTER_MOTOR_DELAY)
|
264 |
);
|
265 |
}
|
|
|
|
|
|
|
266 |
}
|
267 |
|
268 |
+
/**
|
269 |
+
* Lock motors (motors will hold their positions - perfect after calibration)
|
270 |
+
*/
|
271 |
+
export async function lockMotors(
|
272 |
+
port: MotorCommunicationPort,
|
273 |
+
motorIds: number[]
|
274 |
+
): Promise<void> {
|
275 |
+
for (const motorId of motorIds) {
|
276 |
+
await lockMotor(port, motorId);
|
277 |
+
// Small delay between motors
|
278 |
+
await new Promise((resolve) =>
|
279 |
+
setTimeout(resolve, STS3215_PROTOCOL.INTER_MOTOR_DELAY)
|
280 |
+
);
|
281 |
+
}
|
282 |
}
|
src/lerobot/web/utils/serial-port-wrapper.ts
ADDED
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Web Serial Port Wrapper
|
3 |
+
* Standardized Web Serial API interface with immediate lock release
|
4 |
+
*/
|
5 |
+
|
6 |
+
/**
|
7 |
+
* Web Serial Port wrapper - direct write/read with immediate lock release
|
8 |
+
* Follows Chrome documentation exactly for proper Web Serial API usage
|
9 |
+
*/
|
10 |
+
export class WebSerialPortWrapper {
|
11 |
+
private port: SerialPort;
|
12 |
+
|
13 |
+
constructor(port: SerialPort) {
|
14 |
+
this.port = port;
|
15 |
+
}
|
16 |
+
|
17 |
+
get isOpen(): boolean {
|
18 |
+
return (
|
19 |
+
this.port !== null &&
|
20 |
+
this.port.readable !== null &&
|
21 |
+
this.port.writable !== null
|
22 |
+
);
|
23 |
+
}
|
24 |
+
|
25 |
+
async initialize(): Promise<void> {
|
26 |
+
if (!this.port.readable || !this.port.writable) {
|
27 |
+
throw new Error("Port is not open for reading/writing");
|
28 |
+
}
|
29 |
+
}
|
30 |
+
|
31 |
+
async write(data: Uint8Array): Promise<void> {
|
32 |
+
if (!this.port.writable) {
|
33 |
+
throw new Error("Port not open for writing");
|
34 |
+
}
|
35 |
+
|
36 |
+
const writer = this.port.writable.getWriter();
|
37 |
+
try {
|
38 |
+
await writer.write(data);
|
39 |
+
} finally {
|
40 |
+
writer.releaseLock();
|
41 |
+
}
|
42 |
+
}
|
43 |
+
|
44 |
+
async read(timeout: number = 1000): Promise<Uint8Array> {
|
45 |
+
if (!this.port.readable) {
|
46 |
+
throw new Error("Port not open for reading");
|
47 |
+
}
|
48 |
+
|
49 |
+
const reader = this.port.readable.getReader();
|
50 |
+
|
51 |
+
try {
|
52 |
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
53 |
+
setTimeout(() => reject(new Error("Read timeout")), timeout);
|
54 |
+
});
|
55 |
+
|
56 |
+
const result = await Promise.race([reader.read(), timeoutPromise]);
|
57 |
+
const { value, done } = result;
|
58 |
+
|
59 |
+
if (done || !value) {
|
60 |
+
throw new Error("Read failed - port closed or no data");
|
61 |
+
}
|
62 |
+
|
63 |
+
return new Uint8Array(value);
|
64 |
+
} finally {
|
65 |
+
reader.releaseLock();
|
66 |
+
}
|
67 |
+
}
|
68 |
+
|
69 |
+
async close(): Promise<void> {
|
70 |
+
// Don't close the port itself - just wrapper cleanup
|
71 |
+
}
|
72 |
+
}
|
src/lerobot/web/utils/sign-magnitude.ts
ADDED
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Sign-Magnitude Encoding Utilities
|
3 |
+
* For STS3215 motor homing offset encoding/decoding
|
4 |
+
*/
|
5 |
+
|
6 |
+
import { STS3215_PROTOCOL } from "./sts3215-protocol.js";
|
7 |
+
|
8 |
+
/**
|
9 |
+
* Encode a signed integer using sign-magnitude format
|
10 |
+
* Bit at sign_bit_index represents sign (0=positive, 1=negative)
|
11 |
+
* Lower bits represent magnitude
|
12 |
+
*/
|
13 |
+
export function encodeSignMagnitude(
|
14 |
+
value: number,
|
15 |
+
signBitIndex: number = STS3215_PROTOCOL.SIGN_MAGNITUDE_BIT
|
16 |
+
): number {
|
17 |
+
const maxMagnitude = (1 << signBitIndex) - 1;
|
18 |
+
const magnitude = Math.abs(value);
|
19 |
+
|
20 |
+
if (magnitude > maxMagnitude) {
|
21 |
+
throw new Error(
|
22 |
+
`Magnitude ${magnitude} exceeds ${maxMagnitude} (max for signBitIndex=${signBitIndex})`
|
23 |
+
);
|
24 |
+
}
|
25 |
+
|
26 |
+
const directionBit = value < 0 ? 1 : 0;
|
27 |
+
return (directionBit << signBitIndex) | magnitude;
|
28 |
+
}
|
29 |
+
|
30 |
+
/**
|
31 |
+
* Decode a sign-magnitude encoded value back to signed integer
|
32 |
+
* Extracts sign bit and magnitude, then applies sign
|
33 |
+
*/
|
34 |
+
export function decodeSignMagnitude(
|
35 |
+
encodedValue: number,
|
36 |
+
signBitIndex: number = STS3215_PROTOCOL.SIGN_MAGNITUDE_BIT
|
37 |
+
): number {
|
38 |
+
const signBit = (encodedValue >> signBitIndex) & 1;
|
39 |
+
const magnitude = encodedValue & ((1 << signBitIndex) - 1);
|
40 |
+
return signBit ? -magnitude : magnitude;
|
41 |
+
}
|
src/lerobot/web/utils/sts3215-protocol.ts
ADDED
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* STS3215 Protocol Constants and Configuration
|
3 |
+
* Single source of truth for all STS3215 motor communication
|
4 |
+
*/
|
5 |
+
|
6 |
+
/**
|
7 |
+
* STS3215 Protocol Configuration
|
8 |
+
* Register addresses, timing, and communication constants
|
9 |
+
*/
|
10 |
+
export const STS3215_PROTOCOL = {
|
11 |
+
// Register addresses
|
12 |
+
PRESENT_POSITION_ADDRESS: 56,
|
13 |
+
GOAL_POSITION_ADDRESS: 42,
|
14 |
+
HOMING_OFFSET_ADDRESS: 31,
|
15 |
+
MIN_POSITION_LIMIT_ADDRESS: 9,
|
16 |
+
MAX_POSITION_LIMIT_ADDRESS: 11,
|
17 |
+
TORQUE_ENABLE_ADDRESS: 40, // Torque Enable register (0=disable, 1=enable)
|
18 |
+
|
19 |
+
// Protocol constants
|
20 |
+
RESOLUTION: 4096, // 12-bit resolution (0-4095)
|
21 |
+
SIGN_MAGNITUDE_BIT: 11, // Bit 11 is sign bit for Homing_Offset encoding
|
22 |
+
|
23 |
+
// Data lengths
|
24 |
+
HOMING_OFFSET_LENGTH: 2,
|
25 |
+
PRESENT_POSITION_LENGTH: 2,
|
26 |
+
MIN_POSITION_LIMIT_LENGTH: 2,
|
27 |
+
MAX_POSITION_LIMIT_LENGTH: 2,
|
28 |
+
|
29 |
+
// Communication timing (proven from calibration)
|
30 |
+
WRITE_TO_READ_DELAY: 10,
|
31 |
+
RETRY_DELAY: 20,
|
32 |
+
INTER_MOTOR_DELAY: 10,
|
33 |
+
MAX_RETRIES: 3,
|
34 |
+
} as const;
|
tsconfig.json
CHANGED
@@ -6,6 +6,9 @@
|
|
6 |
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
7 |
"skipLibCheck": true,
|
8 |
|
|
|
|
|
|
|
9 |
/* Bundler mode */
|
10 |
"moduleResolution": "bundler",
|
11 |
"allowImportingTsExtensions": true,
|
|
|
6 |
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
7 |
"skipLibCheck": true,
|
8 |
|
9 |
+
/* React */
|
10 |
+
"jsx": "react-jsx",
|
11 |
+
|
12 |
/* Bundler mode */
|
13 |
"moduleResolution": "bundler",
|
14 |
"allowImportingTsExtensions": true,
|