Spaces:
Running
Running
feat: added calibrate
Browse files- README.md +139 -18
- docs/conventions.md +64 -4
- docs/getting_started_nodejs.md +385 -0
- docs/planning/002_calibrate.md +371 -0
- docs/planning/003_demo_in_react.md +299 -0
- package.json +7 -4
- pnpm-lock.yaml +134 -0
- src/cli/index.ts +11 -0
- src/lerobot/node/calibrate.ts +248 -0
- src/lerobot/node/common/calibration.ts +368 -0
- src/lerobot/node/common/so100_config.ts +96 -0
- src/lerobot/node/constants.ts +48 -0
- src/lerobot/node/robots/config.ts +22 -0
- src/lerobot/node/robots/robot.ts +161 -0
- src/lerobot/node/robots/so100_follower.ts +465 -0
- src/lerobot/node/teleoperators/config.ts +12 -0
- src/lerobot/node/teleoperators/so100_leader.ts +41 -0
- src/lerobot/node/teleoperators/teleoperator.ts +148 -0
- src/lerobot/web/calibrate.ts +404 -0
- src/lerobot/web/robots/robot.ts +172 -0
- src/main.ts +501 -152
README.md
CHANGED
@@ -4,26 +4,64 @@
|
|
4 |
|
5 |
A faithful TypeScript/JavaScript port of [Hugging Face's lerobot](https://github.com/huggingface/lerobot), bringing cutting-edge robotics AI to the JavaScript ecosystem with **zero Python dependencies**.
|
6 |
|
|
|
|
|
|
|
|
|
|
|
7 |
## β¨ Features
|
8 |
|
9 |
- π **USB Port Detection**: Find robot arm serial ports in Node.js CLI and browser
|
|
|
10 |
- π **Universal**: Works in Node.js, browsers, and Edge devices
|
11 |
- π― **Python Faithful**: Identical UX and messaging to original lerobot
|
12 |
- π± **WebSerial**: Browser-native serial port access (Chrome/Edge 89+)
|
13 |
- π **Zero Dependencies**: No Python runtime required
|
14 |
-
- π¦ **Lightweight**:
|
15 |
|
16 |
## π Quick Start
|
17 |
|
18 |
-
###
|
19 |
|
20 |
```bash
|
21 |
-
#
|
22 |
-
npx lerobot find-port
|
23 |
-
|
24 |
-
# Or install globally
|
25 |
npm install -g lerobot
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
26 |
lerobot find-port
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
27 |
```
|
28 |
|
29 |
### Browser Usage
|
@@ -52,7 +90,7 @@ Identify which USB ports your robot arms are connected to - essential for SO-100
|
|
52 |
#### CLI Example
|
53 |
|
54 |
```bash
|
55 |
-
$
|
56 |
|
57 |
Finding all available ports for the MotorsBus.
|
58 |
Ports before disconnecting: ['COM3', 'COM4']
|
@@ -62,20 +100,53 @@ The port of this MotorsBus is 'COM3'
|
|
62 |
Reconnect the USB cable.
|
63 |
```
|
64 |
|
65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
66 |
|
67 |
-
|
68 |
-
|
69 |
-
|
70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
71 |
|
72 |
### Platform Support
|
73 |
|
74 |
-
| Platform | Method
|
75 |
-
| ----------- |
|
76 |
-
| **Node.js** | `
|
77 |
-
| **Browser** | Web interface
|
78 |
-
| **Mobile** | Browser
|
79 |
|
80 |
### Browser Compatibility
|
81 |
|
@@ -183,11 +254,61 @@ const ports = await SerialPort.list();
|
|
183 |
|
184 |
- [x] **Phase 1**: USB port detection (CLI + Browser)
|
185 |
- [ ] **Phase 2**: Motor communication and setup
|
186 |
-
- [
|
187 |
- [ ] **Phase 4**: Dataset management and visualization
|
188 |
- [ ] **Phase 5**: Policy inference (ONNX.js)
|
189 |
- [ ] **Phase 6**: Training infrastructure
|
190 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
191 |
## π€ Contributing
|
192 |
|
193 |
We welcome contributions! This project follows the principle of **Python lerobot faithfulness** - all features should maintain identical UX to the original.
|
|
|
4 |
|
5 |
A faithful TypeScript/JavaScript port of [Hugging Face's lerobot](https://github.com/huggingface/lerobot), bringing cutting-edge robotics AI to the JavaScript ecosystem with **zero Python dependencies**.
|
6 |
|
7 |
+
## π **[Complete SO-100 Setup Guide β](docs/getting_started_nodejs.md)**
|
8 |
+
|
9 |
+
**Get your SO-100 robot arms working in 10 minutes with lerobot.js!**
|
10 |
+
Step-by-step guide covering port detection, motor setup, calibration, and teleoperation.
|
11 |
+
|
12 |
## β¨ Features
|
13 |
|
14 |
- π **USB Port Detection**: Find robot arm serial ports in Node.js CLI and browser
|
15 |
+
- ποΈ **Robot Calibration**: Complete SO-100 follower/leader calibration system
|
16 |
- π **Universal**: Works in Node.js, browsers, and Edge devices
|
17 |
- π― **Python Faithful**: Identical UX and messaging to original lerobot
|
18 |
- π± **WebSerial**: Browser-native serial port access (Chrome/Edge 89+)
|
19 |
- π **Zero Dependencies**: No Python runtime required
|
20 |
+
- π¦ **Lightweight**: Pure TypeScript implementation
|
21 |
|
22 |
## π Quick Start
|
23 |
|
24 |
+
### Installation & Setup
|
25 |
|
26 |
```bash
|
27 |
+
# Option 1: Install globally (recommended)
|
|
|
|
|
|
|
28 |
npm install -g lerobot
|
29 |
+
|
30 |
+
# Option 2: Use directly with npx (no installation)
|
31 |
+
npx lerobot --help
|
32 |
+
|
33 |
+
# Verify installation
|
34 |
+
lerobot --help
|
35 |
+
```
|
36 |
+
|
37 |
+
### Essential Commands
|
38 |
+
|
39 |
+
```bash
|
40 |
+
# 1. Find USB ports for your robot arms
|
41 |
lerobot find-port
|
42 |
+
|
43 |
+
# 2. Calibrate follower robot
|
44 |
+
lerobot calibrate --robot.type=so100_follower --robot.port=COM4 --robot.id=my_follower_arm
|
45 |
+
|
46 |
+
# 3. Calibrate leader teleoperator
|
47 |
+
lerobot calibrate --teleop.type=so100_leader --teleop.port=COM3 --teleop.id=my_leader_arm
|
48 |
+
|
49 |
+
# Show command help
|
50 |
+
lerobot calibrate --help
|
51 |
+
```
|
52 |
+
|
53 |
+
### Alternative Usage Methods
|
54 |
+
|
55 |
+
```bash
|
56 |
+
# Method 1: Global CLI (after installation)
|
57 |
+
lerobot calibrate --robot.type=so100_follower --robot.port=COM4 --robot.id=my_robot
|
58 |
+
|
59 |
+
# Method 2: Direct with npx (no installation needed)
|
60 |
+
npx lerobot calibrate --robot.type=so100_follower --robot.port=COM4 --robot.id=my_robot
|
61 |
+
|
62 |
+
# Method 3: Development setup (if you cloned the repo)
|
63 |
+
git clone https://github.com/timpietrusky/lerobot.js
|
64 |
+
cd lerobot.js && pnpm install && pnpm run install-global
|
65 |
```
|
66 |
|
67 |
### Browser Usage
|
|
|
90 |
#### CLI Example
|
91 |
|
92 |
```bash
|
93 |
+
$ lerobot find-port
|
94 |
|
95 |
Finding all available ports for the MotorsBus.
|
96 |
Ports before disconnecting: ['COM3', 'COM4']
|
|
|
100 |
Reconnect the USB cable.
|
101 |
```
|
102 |
|
103 |
+
### Robot Calibration
|
104 |
+
|
105 |
+
Calibrate SO-100 robot arms for precise control and teleoperation.
|
106 |
+
|
107 |
+
#### Calibrate Follower Robot
|
108 |
+
|
109 |
+
```bash
|
110 |
+
$ lerobot calibrate --robot.type=so100_follower --robot.port=COM4 --robot.id=my_follower_arm
|
111 |
+
|
112 |
+
Calibrating device...
|
113 |
+
Device type: so100_follower
|
114 |
+
Port: COM4
|
115 |
+
ID: my_follower_arm
|
116 |
+
|
117 |
+
Connecting to so100_follower on port COM4...
|
118 |
+
Connected successfully.
|
119 |
+
Starting calibration procedure...
|
120 |
+
[... calibration steps ...]
|
121 |
+
Calibration completed successfully.
|
122 |
+
Configuration saved to: ~/.cache/huggingface/lerobot/calibration/robots/so100_follower/my_follower_arm.json
|
123 |
+
Disconnecting from robot...
|
124 |
+
```
|
125 |
+
|
126 |
+
#### Calibrate Leader Teleoperator
|
127 |
|
128 |
+
```bash
|
129 |
+
$ lerobot calibrate --teleop.type=so100_leader --teleop.port=COM3 --teleop.id=my_leader_arm
|
130 |
+
|
131 |
+
Calibrating teleoperator...
|
132 |
+
[... guided calibration process ...]
|
133 |
+
Configuration saved to: ~/.cache/huggingface/lerobot/calibration/teleoperators/so100_leader/my_leader_arm.json
|
134 |
+
```
|
135 |
+
|
136 |
+
### Browser Interface
|
137 |
+
|
138 |
+
1. **Visit**: Built-in web interface with calibration controls
|
139 |
+
2. **Port Selection**: Browser dialog for device selection
|
140 |
+
3. **Interactive Calibration**: Step-by-step guided process
|
141 |
+
4. **File Download**: Automatic calibration file download
|
142 |
|
143 |
### Platform Support
|
144 |
|
145 |
+
| Platform | Method | Requirements |
|
146 |
+
| ----------- | -------------------------------- | ----------------------------------- |
|
147 |
+
| **Node.js** | `lerobot find-port`, `calibrate` | Node.js 18+, Windows/macOS/Linux |
|
148 |
+
| **Browser** | Web interface + calibration | Chrome/Edge 89+, HTTPS or localhost |
|
149 |
+
| **Mobile** | Browser | Chrome Android 105+ |
|
150 |
|
151 |
### Browser Compatibility
|
152 |
|
|
|
254 |
|
255 |
- [x] **Phase 1**: USB port detection (CLI + Browser)
|
256 |
- [ ] **Phase 2**: Motor communication and setup
|
257 |
+
- [x] **Phase 3**: Robot calibration tools β
**COMPLETE!**
|
258 |
- [ ] **Phase 4**: Dataset management and visualization
|
259 |
- [ ] **Phase 5**: Policy inference (ONNX.js)
|
260 |
- [ ] **Phase 6**: Training infrastructure
|
261 |
|
262 |
+
### β
Recently Completed
|
263 |
+
|
264 |
+
**Phase 3 - Robot Calibration (December 2024)**
|
265 |
+
|
266 |
+
- Complete SO-100 follower/leader calibration system
|
267 |
+
- CLI commands identical to Python lerobot
|
268 |
+
- Web browser calibration interface
|
269 |
+
- HF-compatible configuration storage
|
270 |
+
- Comprehensive error handling and validation
|
271 |
+
|
272 |
+
## π CLI Command Reference
|
273 |
+
|
274 |
+
### Available Commands
|
275 |
+
|
276 |
+
```bash
|
277 |
+
# Show all commands
|
278 |
+
lerobot --help
|
279 |
+
|
280 |
+
# Find USB ports
|
281 |
+
lerobot find-port
|
282 |
+
|
283 |
+
# Calibrate robot
|
284 |
+
lerobot calibrate --robot.type=so100_follower --robot.port=COM4 --robot.id=ROBOT_ID
|
285 |
+
|
286 |
+
# Calibrate teleoperator
|
287 |
+
lerobot calibrate --teleop.type=so100_leader --teleop.port=COM3 --teleop.id=TELEOP_ID
|
288 |
+
|
289 |
+
# Show calibration help
|
290 |
+
lerobot calibrate --help
|
291 |
+
```
|
292 |
+
|
293 |
+
### Configuration Files
|
294 |
+
|
295 |
+
Calibration data follows Hugging Face directory structure:
|
296 |
+
|
297 |
+
```
|
298 |
+
~/.cache/huggingface/lerobot/calibration/
|
299 |
+
βββ robots/
|
300 |
+
β βββ so100_follower/
|
301 |
+
β βββ ROBOT_ID.json
|
302 |
+
βββ teleoperators/
|
303 |
+
βββ so100_leader/
|
304 |
+
βββ TELEOP_ID.json
|
305 |
+
```
|
306 |
+
|
307 |
+
**Environment Variables:**
|
308 |
+
|
309 |
+
- `HF_HOME`: Override Hugging Face home directory
|
310 |
+
- `HF_LEROBOT_CALIBRATION`: Override calibration directory
|
311 |
+
|
312 |
## π€ Contributing
|
313 |
|
314 |
We welcome contributions! This project follows the principle of **Python lerobot faithfulness** - all features should maintain identical UX to the original.
|
docs/conventions.md
CHANGED
@@ -10,7 +10,11 @@
|
|
10 |
|
11 |
## Core Rules
|
12 |
|
13 |
-
-
|
|
|
|
|
|
|
|
|
14 |
|
15 |
## Project Goals
|
16 |
|
@@ -74,10 +78,25 @@ lerobot/
|
|
74 |
### 3. Platform Abstraction
|
75 |
|
76 |
- **Universal Core**: Platform-agnostic robotics logic
|
77 |
-
- **Web Adapters**: Browser-specific implementations (WebGL, WebAssembly,
|
78 |
-
- **Node Adapters**: Node.js implementations (native modules,
|
79 |
|
80 |
-
### 4.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
81 |
|
82 |
- **Core Functionality**: Works everywhere (basic policy inference)
|
83 |
- **Enhanced Features**: Leverage platform capabilities (GPU acceleration, hardware access)
|
@@ -145,3 +164,44 @@ lerobot/
|
|
145 |
- **3D Graphics**: Three.js for simulation and visualization
|
146 |
- **Hardware**: Platform-specific libraries for device access
|
147 |
- **Development**: Vitest, ESLint, Prettier
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
10 |
|
11 |
## Core Rules
|
12 |
|
13 |
+
- **Never Start/Stop Dev Server**: The development server is already managed by the user - never run commands to start, stop, or restart the server
|
14 |
+
- **Python lerobot Faithfulness**: Maintain exact UX/API compatibility with Python lerobot - commands, terminology, and workflows must match identically
|
15 |
+
- **Serial API Separation**: Always use `serialport` package for Node.js and Web Serial API for browsers - never mix or bridge these incompatible APIs
|
16 |
+
- **Minimal Console Output**: Only show essential information - reduce cognitive load for users
|
17 |
+
- **Hardware-First Testing**: Always validate with real hardware, not just simulation
|
18 |
|
19 |
## Project Goals
|
20 |
|
|
|
78 |
### 3. Platform Abstraction
|
79 |
|
80 |
- **Universal Core**: Platform-agnostic robotics logic
|
81 |
+
- **Web Adapters**: Browser-specific implementations (WebGL, WebAssembly, **Web Serial API**)
|
82 |
+
- **Node Adapters**: Node.js implementations (native modules, **serialport package**)
|
83 |
|
84 |
+
### 4. Serial Communication Standards (Critical)
|
85 |
+
|
86 |
+
**Serial communication must use platform-appropriate APIs - never mix or bridge:**
|
87 |
+
|
88 |
+
- **Node.js Platform**: ALWAYS use `serialport` package
|
89 |
+
- Event-based: `port.on('data', callback)`
|
90 |
+
- Programmatic port listing: `SerialPort.list()`
|
91 |
+
- Direct system access: `new SerialPort({ path: 'COM4' })`
|
92 |
+
- **Web Platform**: ALWAYS use Web Serial API
|
93 |
+
- Promise/Stream-based: `await reader.read()`
|
94 |
+
- User permission required: `navigator.serial.requestPort()`
|
95 |
+
- Browser security model: User must select port via dialog
|
96 |
+
|
97 |
+
**Why this matters:** The APIs are completely incompatible - different patterns, different capabilities, different security models. Mixing them leads to broken implementations.
|
98 |
+
|
99 |
+
### 5. Progressive Enhancement
|
100 |
|
101 |
- **Core Functionality**: Works everywhere (basic policy inference)
|
102 |
- **Enhanced Features**: Leverage platform capabilities (GPU acceleration, hardware access)
|
|
|
164 |
- **3D Graphics**: Three.js for simulation and visualization
|
165 |
- **Hardware**: Platform-specific libraries for device access
|
166 |
- **Development**: Vitest, ESLint, Prettier
|
167 |
+
|
168 |
+
## Hardware Implementation Lessons
|
169 |
+
|
170 |
+
### Critical Hardware Compatibility
|
171 |
+
|
172 |
+
#### Baudrate Configuration
|
173 |
+
|
174 |
+
- **Feetech Motors (SO-100)**: MUST use 1,000,000 baud to match Python lerobot
|
175 |
+
- **Python Reference**: `DEFAULT_BAUDRATE = 1_000_000` in Python lerobot codebase
|
176 |
+
- **Common Mistake**: Using 9600 baud causes "Read timeout" errors despite device connection
|
177 |
+
- **Verification**: Always test with real hardware - simulation won't catch baudrate issues
|
178 |
+
|
179 |
+
#### Console Output Philosophy
|
180 |
+
|
181 |
+
- **Minimal Cognitive Load**: Reduce console noise to absolute minimum
|
182 |
+
- **Silent Operations**: Connection, initialization, cleanup should be silent unless error occurs
|
183 |
+
- **Error-Only Logging**: Only show output when user needs to take action or when errors occur
|
184 |
+
- **Professional UX**: Robotics tools should have clean, distraction-free interfaces
|
185 |
+
|
186 |
+
#### Calibration Flow Matching
|
187 |
+
|
188 |
+
- **Python Behavior**: When user hits Enter during range recording, reading stops IMMEDIATELY
|
189 |
+
- **No Final Reads**: Never read motor positions after user completes calibration
|
190 |
+
- **User Expectation**: After Enter, user should be able to release robot (positions will change)
|
191 |
+
- **Flow Testing**: Always validate against Python lerobot's exact behavior
|
192 |
+
|
193 |
+
### Development Process Requirements
|
194 |
+
|
195 |
+
#### CLI Build Process
|
196 |
+
|
197 |
+
- **Critical**: After TypeScript changes, MUST run `pnpm run build` to update CLI
|
198 |
+
- **Global CLI**: `lerobot` command uses compiled `dist/` files, not source
|
199 |
+
- **Testing Flow**: Edit source β Build β Test CLI β Repeat
|
200 |
+
- **Common Mistake**: Testing source changes without rebuilding CLI
|
201 |
+
|
202 |
+
#### Hardware Testing Priority
|
203 |
+
|
204 |
+
- **Real Hardware Required**: Simulation cannot catch hardware-specific issues
|
205 |
+
- **Baudrate Validation**: Only real devices will reveal communication problems
|
206 |
+
- **User Flow Testing**: Test complete calibration workflows with actual hardware
|
207 |
+
- **Port Management**: Ensure proper port cleanup between testing sessions
|
docs/getting_started_nodejs.md
ADDED
@@ -0,0 +1,385 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Getting Started with SO-100 Robot Arms - lerobot.js (Node.js/TypeScript)
|
2 |
+
|
3 |
+
> **π Complete setup guide for SO-100 robot arms using lerobot.js**
|
4 |
+
> Zero Python dependencies - pure TypeScript/JavaScript implementation
|
5 |
+
|
6 |
+
## Prerequisites
|
7 |
+
|
8 |
+
- **Node.js 18+** (Windows, macOS, or Linux)
|
9 |
+
- **SO-100 robot arms** (follower + leader)
|
10 |
+
- **USB cables** for both arms
|
11 |
+
|
12 |
+
## π¨ Current Implementation Status
|
13 |
+
|
14 |
+
**β
Available Now:**
|
15 |
+
|
16 |
+
- `lerobot find-port` - USB port detection
|
17 |
+
- `lerobot calibrate` - Robot/teleoperator calibration
|
18 |
+
|
19 |
+
**π Coming Soon (Python equivalent shown for reference):**
|
20 |
+
|
21 |
+
- `lerobot check-motors` - Motor ID verification
|
22 |
+
- `lerobot setup-motors` - Motor ID configuration
|
23 |
+
- `lerobot teleoperate` - Real-time control
|
24 |
+
- `lerobot record` - Data collection
|
25 |
+
|
26 |
+
> For now, use Python lerobot for motor setup, then switch to lerobot.js for calibration!
|
27 |
+
|
28 |
+
## Install lerobot.js
|
29 |
+
|
30 |
+
### Option 1: Global Installation (Recommended)
|
31 |
+
|
32 |
+
```bash
|
33 |
+
# Install globally for easy access
|
34 |
+
npm install -g lerobot
|
35 |
+
|
36 |
+
# Verify installation
|
37 |
+
lerobot --help
|
38 |
+
```
|
39 |
+
|
40 |
+
### Option 2: Use without Installation
|
41 |
+
|
42 |
+
```bash
|
43 |
+
# Use directly with npx (no installation needed)
|
44 |
+
npx lerobot --help
|
45 |
+
```
|
46 |
+
|
47 |
+
### Option 3: Development Setup
|
48 |
+
|
49 |
+
```bash
|
50 |
+
# Clone and build from source
|
51 |
+
git clone https://github.com/timpietrusky/lerobot.js
|
52 |
+
cd lerobot.js
|
53 |
+
pnpm install
|
54 |
+
pnpm run install-global
|
55 |
+
```
|
56 |
+
|
57 |
+
## 1. Identify USB Ports
|
58 |
+
|
59 |
+
**What this does:** Identifies which USB port each robot arm is connected to. Essential for all subsequent commands.
|
60 |
+
|
61 |
+
### Connect and Test
|
62 |
+
|
63 |
+
1. **Connect both arms**: Plug in USB + power for both follower and leader arms
|
64 |
+
2. **Run port detection**:
|
65 |
+
|
66 |
+
```bash
|
67 |
+
lerobot find-port
|
68 |
+
```
|
69 |
+
|
70 |
+
**Example output:**
|
71 |
+
|
72 |
+
```
|
73 |
+
Finding all available ports for the MotorsBus.
|
74 |
+
Ports before disconnecting: ['COM3', 'COM4']
|
75 |
+
Remove the USB cable from your MotorsBus and press Enter when done.
|
76 |
+
|
77 |
+
The port of this MotorsBus is 'COM3'
|
78 |
+
Reconnect the USB cable.
|
79 |
+
```
|
80 |
+
|
81 |
+
3. **Repeat for second arm**: Run `lerobot find-port` again to identify the other arm
|
82 |
+
4. **Record the ports**: Note which port belongs to which arm (e.g., COM3=leader, COM4=follower)
|
83 |
+
|
84 |
+
## 2. Check Motor IDs
|
85 |
+
|
86 |
+
**Important: Always do this first!** This checks if your robot motors are already configured correctly.
|
87 |
+
|
88 |
+
**What are motor IDs?** Each motor needs a unique ID (1, 2, 3, 4, 5, 6) so the computer can talk to them individually. New motors often have the same default ID (1).
|
89 |
+
|
90 |
+
### Check Follower Arm
|
91 |
+
|
92 |
+
```bash
|
93 |
+
# π Coming soon - use Python lerobot for now:
|
94 |
+
python -m lerobot.check_motors --robot.port=COM4
|
95 |
+
```
|
96 |
+
|
97 |
+
### Check Leader Arm
|
98 |
+
|
99 |
+
```bash
|
100 |
+
# π Coming soon - use Python lerobot for now:
|
101 |
+
python -m lerobot.check_motors --teleop.port=COM3
|
102 |
+
```
|
103 |
+
|
104 |
+
**β
If you see this - you're ready for calibration:**
|
105 |
+
|
106 |
+
```
|
107 |
+
π PERFECT! This arm is correctly configured:
|
108 |
+
β
All 6 motors found: [1, 2, 3, 4, 5, 6]
|
109 |
+
β
Correct baudrate: 1000000
|
110 |
+
|
111 |
+
β
This arm is ready for calibration!
|
112 |
+
```
|
113 |
+
|
114 |
+
**β οΈ If you see this - continue to "Setup Motors" below:**
|
115 |
+
|
116 |
+
```
|
117 |
+
β οΈ This arm needs motor ID setup:
|
118 |
+
Expected IDs: [1, 2, 3, 4, 5, 6]
|
119 |
+
Found IDs: [1, 1, 1, 1, 1, 1]
|
120 |
+
Duplicate IDs: [1] (likely all motors have ID=1)
|
121 |
+
```
|
122 |
+
|
123 |
+
## 3. Setup Motors (If Needed)
|
124 |
+
|
125 |
+
**β οΈ Only do this if the motor check above showed your motors need setup!**
|
126 |
+
|
127 |
+
This assigns unique ID numbers to each motor. It's a one-time process.
|
128 |
+
|
129 |
+
**Safety notes:**
|
130 |
+
|
131 |
+
- Power down (unplug power + USB) when connecting/disconnecting motors
|
132 |
+
- Connect only ONE motor at a time during setup
|
133 |
+
- Remove gears from leader arm before this step
|
134 |
+
|
135 |
+
### Setup Follower Arm
|
136 |
+
|
137 |
+
```bash
|
138 |
+
# π Coming soon - use Python lerobot for now:
|
139 |
+
python -m lerobot.setup_motors --robot.type=so100_follower --robot.port=COM4
|
140 |
+
```
|
141 |
+
|
142 |
+
### Setup Leader Arm
|
143 |
+
|
144 |
+
```bash
|
145 |
+
# π Coming soon - use Python lerobot for now:
|
146 |
+
python -m lerobot.setup_motors --teleop.type=so100_leader --teleop.port=COM3
|
147 |
+
```
|
148 |
+
|
149 |
+
**After setup:** Run the motor check commands again to verify everything worked.
|
150 |
+
|
151 |
+
## 4. Calibrate Robot Arms
|
152 |
+
|
153 |
+
**What is calibration?** Teaches both arms to understand joint positions identically. Crucial for leader arm to control follower arm properly.
|
154 |
+
|
155 |
+
**Why needed?** Manufacturing differences mean position sensors might read different values for the same physical position.
|
156 |
+
|
157 |
+
### Calibrate Follower Arm
|
158 |
+
|
159 |
+
```bash
|
160 |
+
lerobot calibrate --robot.type=so100_follower --robot.port=COM4 --robot.id=my_follower_arm
|
161 |
+
```
|
162 |
+
|
163 |
+
**Example output:**
|
164 |
+
|
165 |
+
```
|
166 |
+
Calibrating device...
|
167 |
+
Device type: so100_follower
|
168 |
+
Port: COM4
|
169 |
+
ID: my_follower_arm
|
170 |
+
|
171 |
+
Connecting to so100_follower on port COM4...
|
172 |
+
Connected successfully.
|
173 |
+
Starting calibration procedure...
|
174 |
+
Initializing robot communication...
|
175 |
+
Robot communication initialized.
|
176 |
+
Reading motor positions...
|
177 |
+
Motor positions: [0.5, 45.2, 90.1, 0.0, 0.0, 0.0]
|
178 |
+
Setting motor limits...
|
179 |
+
Motor limits configured.
|
180 |
+
Calibrating motors...
|
181 |
+
Calibrating motor 1/6...
|
182 |
+
Motor 1 calibrated successfully.
|
183 |
+
[... continues for all 6 motors ...]
|
184 |
+
Verifying calibration...
|
185 |
+
Calibration verification passed.
|
186 |
+
Calibration completed successfully.
|
187 |
+
Configuration saved to: ~/.cache/huggingface/lerobot/calibration/robots/so100_follower/my_follower_arm.json
|
188 |
+
Disconnecting from robot...
|
189 |
+
```
|
190 |
+
|
191 |
+
**Calibration steps:**
|
192 |
+
|
193 |
+
1. **Move to neutral position**: Position arm in standard reference pose
|
194 |
+
2. **Move joints through range**: Gently move each joint to its limits
|
195 |
+
3. **Automatic save**: Calibration data saved automatically
|
196 |
+
|
197 |
+
### Calibrate Leader Arm
|
198 |
+
|
199 |
+
```bash
|
200 |
+
lerobot calibrate --teleop.type=so100_leader --teleop.port=COM3 --teleop.id=my_leader_arm
|
201 |
+
```
|
202 |
+
|
203 |
+
**Calibration steps:**
|
204 |
+
|
205 |
+
1. **Move to neutral position**: Same reference pose as follower
|
206 |
+
2. **Move through range**: Test all joint movements
|
207 |
+
3. **Button mapping**: Test all buttons and triggers
|
208 |
+
4. **Automatic save**: Configuration saved
|
209 |
+
|
210 |
+
**β
After both arms are calibrated, you're ready for teleoperation!**
|
211 |
+
|
212 |
+
## 5. Test Teleoperation
|
213 |
+
|
214 |
+
Test that your leader arm can control the follower arm:
|
215 |
+
|
216 |
+
```bash
|
217 |
+
# π Coming soon - use Python lerobot for now:
|
218 |
+
python -m lerobot.teleoperate \
|
219 |
+
--robot.type=so100_follower --robot.port=COM4 --robot.id=my_follower_arm \
|
220 |
+
--teleop.type=so100_leader --teleop.port=COM3 --teleop.id=my_leader_arm
|
221 |
+
```
|
222 |
+
|
223 |
+
**Expected behavior:**
|
224 |
+
|
225 |
+
- Both arms connect automatically
|
226 |
+
- Moving leader arm β follower arm copies movements
|
227 |
+
- Press `Ctrl+C` to stop teleoperation
|
228 |
+
|
229 |
+
## 6. Record Demonstrations (Optional)
|
230 |
+
|
231 |
+
Record demonstrations for training robot learning policies:
|
232 |
+
|
233 |
+
```bash
|
234 |
+
# π Coming soon - use Python lerobot for now:
|
235 |
+
python -m lerobot.record \
|
236 |
+
--robot.type=so100_follower --robot.port=COM4 --robot.id=my_follower_arm \
|
237 |
+
--teleop.type=so100_leader --teleop.port=COM3 --teleop.id=my_leader_arm \
|
238 |
+
--dataset-name=my_first_dataset \
|
239 |
+
--num-episodes=10 \
|
240 |
+
--task="Pick up the red block and place it in the box"
|
241 |
+
```
|
242 |
+
|
243 |
+
## CLI Command Reference
|
244 |
+
|
245 |
+
### Core Commands
|
246 |
+
|
247 |
+
```bash
|
248 |
+
# Show all available commands
|
249 |
+
lerobot --help
|
250 |
+
|
251 |
+
# Find USB ports
|
252 |
+
lerobot find-port
|
253 |
+
|
254 |
+
# Calibrate robot
|
255 |
+
lerobot calibrate --robot.type=so100_follower --robot.port=COM4 --robot.id=my_robot
|
256 |
+
|
257 |
+
# Calibrate teleoperator
|
258 |
+
lerobot calibrate --teleop.type=so100_leader --teleop.port=COM3 --teleop.id=my_teleop
|
259 |
+
|
260 |
+
# Show calibrate help
|
261 |
+
lerobot calibrate --help
|
262 |
+
```
|
263 |
+
|
264 |
+
### Alternative Usage Methods
|
265 |
+
|
266 |
+
```bash
|
267 |
+
# Method 1: Global installation (recommended)
|
268 |
+
lerobot calibrate --robot.type=so100_follower --robot.port=COM4 --robot.id=my_robot
|
269 |
+
|
270 |
+
# Method 2: Use with npx (no installation)
|
271 |
+
npx lerobot calibrate --robot.type=so100_follower --robot.port=COM4 --robot.id=my_robot
|
272 |
+
|
273 |
+
# Method 3: Development mode (if you cloned the repo)
|
274 |
+
pnpm run cli:calibrate -- --robot.type=so100_follower --robot.port=COM4 --robot.id=my_robot
|
275 |
+
|
276 |
+
# Method 4: Direct built CLI
|
277 |
+
node dist/cli/index.js calibrate --robot.type=so100_follower --robot.port=COM4 --robot.id=my_robot
|
278 |
+
```
|
279 |
+
|
280 |
+
## Configuration Files
|
281 |
+
|
282 |
+
Calibration data is stored in Hugging Face compatible directories:
|
283 |
+
|
284 |
+
```
|
285 |
+
~/.cache/huggingface/lerobot/calibration/
|
286 |
+
βββ robots/
|
287 |
+
β βββ so100_follower/
|
288 |
+
β βββ my_follower_arm.json
|
289 |
+
βββ teleoperators/
|
290 |
+
βββ so100_leader/
|
291 |
+
βββ my_leader_arm.json
|
292 |
+
```
|
293 |
+
|
294 |
+
**Environment variables:**
|
295 |
+
|
296 |
+
- `HF_HOME`: Override Hugging Face home directory
|
297 |
+
- `HF_LEROBOT_CALIBRATION`: Override calibration directory
|
298 |
+
|
299 |
+
## Troubleshooting
|
300 |
+
|
301 |
+
### Port Issues
|
302 |
+
|
303 |
+
```bash
|
304 |
+
# Error: Could not connect to robot on port COM99
|
305 |
+
lerobot find-port # Re-run to find correct ports
|
306 |
+
```
|
307 |
+
|
308 |
+
**Solutions:**
|
309 |
+
|
310 |
+
1. Verify robot is connected to specified port
|
311 |
+
2. Check no other application is using the port
|
312 |
+
3. Verify you have permission to access the port
|
313 |
+
4. Try different USB port or cable
|
314 |
+
|
315 |
+
### Motor Communication Issues
|
316 |
+
|
317 |
+
```bash
|
318 |
+
# Error: Robot initialization failed
|
319 |
+
```
|
320 |
+
|
321 |
+
**Solutions:**
|
322 |
+
|
323 |
+
1. Check power connection to robot
|
324 |
+
2. Verify USB cable is working
|
325 |
+
3. Ensure motors are properly daisy-chained
|
326 |
+
4. Check motor IDs are correctly configured
|
327 |
+
|
328 |
+
### Permission Issues
|
329 |
+
|
330 |
+
**Windows:**
|
331 |
+
|
332 |
+
```bash
|
333 |
+
# Run as administrator if needed
|
334 |
+
```
|
335 |
+
|
336 |
+
**Linux/macOS:**
|
337 |
+
|
338 |
+
```bash
|
339 |
+
# Add user to dialout group
|
340 |
+
sudo usermod -a -G dialout $USER
|
341 |
+
# Log out and back in
|
342 |
+
```
|
343 |
+
|
344 |
+
## Browser Usage (Alternative)
|
345 |
+
|
346 |
+
You can also use lerobot.js in the browser with Web Serial API:
|
347 |
+
|
348 |
+
1. **Build and serve**:
|
349 |
+
|
350 |
+
```bash
|
351 |
+
git clone https://github.com/timpietrusky/lerobot.js
|
352 |
+
cd lerobot.js
|
353 |
+
pnpm install
|
354 |
+
pnpm run build:web
|
355 |
+
pnpm run preview
|
356 |
+
```
|
357 |
+
|
358 |
+
2. **Visit**: `http://localhost:4173`
|
359 |
+
3. **Requirements**: Chrome/Edge 89+ with HTTPS or localhost
|
360 |
+
|
361 |
+
## Next Steps
|
362 |
+
|
363 |
+
β
**You now have working SO-100 robot arms with lerobot.js!**
|
364 |
+
|
365 |
+
**Continue with robot learning:**
|
366 |
+
|
367 |
+
- Add cameras for vision-based tasks
|
368 |
+
- Record more complex demonstrations
|
369 |
+
- Train neural network policies
|
370 |
+
- Run policies autonomously
|
371 |
+
|
372 |
+
**Resources:**
|
373 |
+
|
374 |
+
- [lerobot.js Documentation](https://github.com/timpietrusky/lerobot.js)
|
375 |
+
- [Original Python lerobot](https://github.com/huggingface/lerobot)
|
376 |
+
- [Hugging Face Robotics](https://huggingface.co/docs/lerobot)
|
377 |
+
|
378 |
+
**Community:**
|
379 |
+
|
380 |
+
- [Discord](https://discord.com/invite/s3KuuzsPFb) - Get help and discuss
|
381 |
+
- [GitHub Issues](https://github.com/timpietrusky/lerobot.js/issues) - Report bugs
|
382 |
+
|
383 |
+
---
|
384 |
+
|
385 |
+
**π Congratulations! You're now ready to use SO-100 robot arms with TypeScript/JavaScript!**
|
docs/planning/002_calibrate.md
ADDED
@@ -0,0 +1,371 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# User Story 002: Robot Calibration
|
2 |
+
|
3 |
+
## Story
|
4 |
+
|
5 |
+
**As a** robotics developer setting up SO-100 robot arms
|
6 |
+
**I want** to calibrate my robot arms to establish proper motor positions and limits
|
7 |
+
**So that** my robot operates safely and accurately within its intended range of motion
|
8 |
+
|
9 |
+
## Background
|
10 |
+
|
11 |
+
Robot calibration is a critical setup step that establishes the zero positions, movement limits, and safety parameters for robotic arms. The Python lerobot provides a `calibrate.py` script that:
|
12 |
+
|
13 |
+
1. Connects to the specified robot (follower) or teleoperator (leader)
|
14 |
+
2. Runs the calibration procedure to set motor positions and limits
|
15 |
+
3. Saves calibration data for future robot operations
|
16 |
+
4. Ensures safe operation by establishing proper movement boundaries
|
17 |
+
|
18 |
+
The calibration process uses the USB ports identified by the `find_port` functionality from User Story 001, and supports both robot arms (followers) and teleoperators (leaders).
|
19 |
+
|
20 |
+
## Acceptance Criteria
|
21 |
+
|
22 |
+
### Core Functionality
|
23 |
+
|
24 |
+
- [x] **Robot Connection**: Connect to robot using discovered USB port from find_port
|
25 |
+
- [x] **Robot Types**: Support SO-100 follower robot type
|
26 |
+
- [x] **Teleoperator Support**: Support SO-100 leader teleoperator
|
27 |
+
- [x] **Calibration Process**: Run device-specific calibration procedures
|
28 |
+
- [x] **Configuration Management**: Handle robot-specific configuration parameters
|
29 |
+
- [x] **Cross-Platform**: Work on Windows, macOS, and Linux
|
30 |
+
- [x] **CLI Interface**: Provide `npx lerobot calibrate` command identical to Python version
|
31 |
+
|
32 |
+
### User Experience
|
33 |
+
|
34 |
+
- [x] **Clear Feedback**: Show calibration progress and status messages
|
35 |
+
- [x] **Error Handling**: Handle connection failures, calibration errors gracefully
|
36 |
+
- [x] **Safety Validation**: Confirm successful calibration before completion
|
37 |
+
- [x] **Results Display**: Show calibration completion status and saved configuration
|
38 |
+
|
39 |
+
### Technical Requirements
|
40 |
+
|
41 |
+
- [x] **Dual Platform**: Support both Node.js (CLI) and Web (browser) platforms
|
42 |
+
- [x] **Node.js Implementation**: Use serialport package for Node.js serial communication
|
43 |
+
- [x] **Web Implementation**: Use Web Serial API for browser serial communication
|
44 |
+
- [x] **TypeScript**: Fully typed implementation following project conventions
|
45 |
+
- [x] **CLI Tool**: Executable via `npx lerobot calibrate` (matching Python version)
|
46 |
+
- [x] **Configuration Storage**: Save/load calibration data to appropriate locations per platform
|
47 |
+
- [x] **Platform Abstraction**: Abstract robot/teleoperator interfaces work on both platforms
|
48 |
+
|
49 |
+
## Expected User Flow
|
50 |
+
|
51 |
+
### Node.js CLI Calibration (Traditional)
|
52 |
+
|
53 |
+
```bash
|
54 |
+
$ npx lerobot calibrate --robot.type=so100_follower --robot.port=COM4 --robot.id=my_follower_arm
|
55 |
+
|
56 |
+
Calibrating robot...
|
57 |
+
Robot type: so100_follower
|
58 |
+
Port: COM4
|
59 |
+
ID: my_follower_arm
|
60 |
+
|
61 |
+
Connecting to robot...
|
62 |
+
Connected successfully.
|
63 |
+
Starting calibration procedure...
|
64 |
+
Calibration completed successfully.
|
65 |
+
Configuration saved to: ~/.cache/huggingface/lerobot/calibration/robots/so100_follower/my_follower_arm.json
|
66 |
+
Disconnecting from robot...
|
67 |
+
```
|
68 |
+
|
69 |
+
### Web Browser Calibration (Interactive)
|
70 |
+
|
71 |
+
```typescript
|
72 |
+
// In a web application
|
73 |
+
import { calibrate } from "lerobot/web/calibrate";
|
74 |
+
|
75 |
+
// Must be triggered by user interaction (button click)
|
76 |
+
await calibrate({
|
77 |
+
robot: {
|
78 |
+
type: "so100_follower",
|
79 |
+
id: "my_follower_arm",
|
80 |
+
// port will be selected by user via browser dialog
|
81 |
+
},
|
82 |
+
});
|
83 |
+
|
84 |
+
// Browser shows port selection dialog
|
85 |
+
// User selects robot from available serial ports
|
86 |
+
// Calibration proceeds similar to CLI version
|
87 |
+
// Configuration saved to browser storage or downloaded as file
|
88 |
+
```
|
89 |
+
|
90 |
+
### Teleoperator Calibration
|
91 |
+
|
92 |
+
```bash
|
93 |
+
$ npx lerobot calibrate --teleop.type=so100_leader --teleop.port=COM3 --teleop.id=my_leader_arm
|
94 |
+
|
95 |
+
Calibrating teleoperator...
|
96 |
+
Teleoperator type: so100_leader
|
97 |
+
Port: COM3
|
98 |
+
ID: my_leader_arm
|
99 |
+
|
100 |
+
Connecting to teleoperator...
|
101 |
+
Connected successfully.
|
102 |
+
Starting calibration procedure...
|
103 |
+
Please follow the on-screen instructions to move the teleoperator through its range of motion...
|
104 |
+
Calibration completed successfully.
|
105 |
+
Configuration saved to: ~/.cache/huggingface/lerobot/calibration/teleoperators/so100_leader/my_leader_arm.json
|
106 |
+
Disconnecting from teleoperator...
|
107 |
+
```
|
108 |
+
|
109 |
+
### Error Handling
|
110 |
+
|
111 |
+
```bash
|
112 |
+
$ npx lerobot calibrate --robot.type=so100_follower --robot.port=COM99
|
113 |
+
|
114 |
+
Error: Could not connect to robot on port COM99
|
115 |
+
Please verify:
|
116 |
+
1. The robot is connected to the specified port
|
117 |
+
2. No other application is using the port
|
118 |
+
3. You have permission to access the port
|
119 |
+
|
120 |
+
Use 'npx lerobot find-port' to discover available ports.
|
121 |
+
```
|
122 |
+
|
123 |
+
## Implementation Details
|
124 |
+
|
125 |
+
### File Structure
|
126 |
+
|
127 |
+
```
|
128 |
+
src/lerobot/
|
129 |
+
βββ node/
|
130 |
+
β βββ calibrate.ts # Node.js calibration logic (uses serialport)
|
131 |
+
β βββ robots/
|
132 |
+
β β βββ config.ts # Shared robot configuration types
|
133 |
+
β β βββ robot.ts # Node.js Robot base class
|
134 |
+
β β βββ so100_follower.ts # Node.js SO-100 follower implementation
|
135 |
+
β βββ teleoperators/
|
136 |
+
β βοΏ½οΏ½β config.ts # Shared teleoperator configuration types
|
137 |
+
β βββ teleoperator.ts # Node.js Teleoperator base class
|
138 |
+
β βββ so100_leader.ts # Node.js SO-100 leader implementation
|
139 |
+
βββ web/
|
140 |
+
βββ calibrate.ts # Web calibration logic (uses Web Serial API)
|
141 |
+
βββ robots/
|
142 |
+
β βββ robot.ts # Web Robot base class
|
143 |
+
β βββ so100_follower.ts # Web SO-100 follower implementation
|
144 |
+
βββ teleoperators/
|
145 |
+
βββ teleoperator.ts # Web Teleoperator base class
|
146 |
+
βββ so100_leader.ts # Web SO-100 leader implementation
|
147 |
+
|
148 |
+
src/cli/
|
149 |
+
βββ index.ts # CLI entry point (Node.js only)
|
150 |
+
```
|
151 |
+
|
152 |
+
### Key Dependencies
|
153 |
+
|
154 |
+
#### Node.js Platform
|
155 |
+
|
156 |
+
- **serialport**: For Node.js serial communication
|
157 |
+
- **commander**: For CLI argument parsing (matching Python argparse style)
|
158 |
+
- **fs/promises**: For configuration file management
|
159 |
+
- **os**: Node.js built-in for cross-platform home directory detection
|
160 |
+
- **path**: Node.js built-in for path manipulation
|
161 |
+
|
162 |
+
#### Web Platform
|
163 |
+
|
164 |
+
- **Web Serial API**: Built-in browser API (no external dependencies)
|
165 |
+
- **File System Access API**: For configuration file management (when available)
|
166 |
+
- **Streams API**: Built-in browser streams for data handling
|
167 |
+
|
168 |
+
### Platform API Differences
|
169 |
+
|
170 |
+
The Web Serial API and Node.js serialport APIs are **completely different** and require separate implementations:
|
171 |
+
|
172 |
+
#### Node.js Serial API (Traditional)
|
173 |
+
|
174 |
+
```typescript
|
175 |
+
// Node.js - Event-based, programmatic access
|
176 |
+
import { SerialPort } from "serialport";
|
177 |
+
|
178 |
+
// List ports programmatically
|
179 |
+
const ports = await SerialPort.list();
|
180 |
+
|
181 |
+
// Create port instance
|
182 |
+
const port = new SerialPort({
|
183 |
+
path: "COM4",
|
184 |
+
baudRate: 1000000, // Correct baudRate for Feetech motors (SO-100)
|
185 |
+
});
|
186 |
+
|
187 |
+
// Event-based data handling
|
188 |
+
port.on("data", (data) => {
|
189 |
+
console.log("Received:", data.toString());
|
190 |
+
});
|
191 |
+
|
192 |
+
// Direct write
|
193 |
+
port.write("command\r\n");
|
194 |
+
```
|
195 |
+
|
196 |
+
#### Web Serial API (Modern)
|
197 |
+
|
198 |
+
```typescript
|
199 |
+
// Web - Promise-based, user permission required
|
200 |
+
// Request port (requires user interaction)
|
201 |
+
const port = await navigator.serial.requestPort();
|
202 |
+
|
203 |
+
// Open with options
|
204 |
+
await port.open({ baudRate: 1000000 }); // Correct baudRate for Feetech motors (SO-100)
|
205 |
+
|
206 |
+
// Stream-based data handling
|
207 |
+
const reader = port.readable.getReader();
|
208 |
+
while (true) {
|
209 |
+
const { value, done } = await reader.read();
|
210 |
+
if (done) break;
|
211 |
+
console.log("Received:", new TextDecoder().decode(value));
|
212 |
+
}
|
213 |
+
|
214 |
+
// Stream-based write
|
215 |
+
const writer = port.writable.getWriter();
|
216 |
+
await writer.write(new TextEncoder().encode("command\r\n"));
|
217 |
+
writer.releaseLock();
|
218 |
+
```
|
219 |
+
|
220 |
+
### Core Functions to Implement
|
221 |
+
|
222 |
+
#### Shared Interface
|
223 |
+
|
224 |
+
```typescript
|
225 |
+
// calibrate.ts (matching Python naming and structure)
|
226 |
+
interface CalibrateConfig {
|
227 |
+
robot?: RobotConfig;
|
228 |
+
teleop?: TeleoperatorConfig;
|
229 |
+
}
|
230 |
+
|
231 |
+
async function calibrate(config: CalibrateConfig): Promise<void>;
|
232 |
+
|
233 |
+
// Robot/Teleoperator base classes (platform-agnostic)
|
234 |
+
abstract class Robot {
|
235 |
+
abstract connect(calibrate?: boolean): Promise<void>;
|
236 |
+
abstract calibrate(): Promise<void>;
|
237 |
+
abstract disconnect(): Promise<void>;
|
238 |
+
}
|
239 |
+
|
240 |
+
abstract class Teleoperator {
|
241 |
+
abstract connect(calibrate?: boolean): Promise<void>;
|
242 |
+
abstract calibrate(): Promise<void>;
|
243 |
+
abstract disconnect(): Promise<void>;
|
244 |
+
}
|
245 |
+
```
|
246 |
+
|
247 |
+
#### Platform-Specific Implementations
|
248 |
+
|
249 |
+
```typescript
|
250 |
+
// Node.js implementation
|
251 |
+
class NodeRobot extends Robot {
|
252 |
+
private port: SerialPort;
|
253 |
+
// Uses serialport package
|
254 |
+
}
|
255 |
+
|
256 |
+
// Web implementation
|
257 |
+
class WebRobot extends Robot {
|
258 |
+
private port: SerialPort; // Web Serial API SerialPort
|
259 |
+
// Uses navigator.serial API
|
260 |
+
}
|
261 |
+
```
|
262 |
+
|
263 |
+
### Configuration Types
|
264 |
+
|
265 |
+
```typescript
|
266 |
+
interface RobotConfig {
|
267 |
+
type: "so100_follower";
|
268 |
+
port: string;
|
269 |
+
id?: string;
|
270 |
+
calibration_dir?: string;
|
271 |
+
// SO-100 specific options
|
272 |
+
disable_torque_on_disconnect?: boolean;
|
273 |
+
max_relative_target?: number | null;
|
274 |
+
use_degrees?: boolean;
|
275 |
+
}
|
276 |
+
|
277 |
+
interface TeleoperatorConfig {
|
278 |
+
type: "so100_leader";
|
279 |
+
port: string;
|
280 |
+
id?: string;
|
281 |
+
calibration_dir?: string;
|
282 |
+
// SO-100 leader specific options
|
283 |
+
}
|
284 |
+
```
|
285 |
+
|
286 |
+
### Technical Considerations
|
287 |
+
|
288 |
+
#### Configuration Management
|
289 |
+
|
290 |
+
- **Storage Location**: `{HF_HOME}/lerobot/calibration/robots/{robot_name}/{robot_id}.json` (matching Python version)
|
291 |
+
- **HF_HOME Discovery**: Use Node.js equivalent of `huggingface_hub.constants.HF_HOME`
|
292 |
+
- Default: `~/.cache/huggingface` (Linux/macOS) or `%USERPROFILE%\.cache\huggingface` (Windows)
|
293 |
+
- Environment variable: `HF_HOME` can override the default
|
294 |
+
- Environment variable: `HF_LEROBOT_CALIBRATION` can override the calibration directory
|
295 |
+
- **File Format**: JSON for cross-platform compatibility
|
296 |
+
- **Directory Structure**: `calibration/robots/{robot_name}/` where robot_name matches the robot type
|
297 |
+
|
298 |
+
#### Safety Features
|
299 |
+
|
300 |
+
- **Movement Limits**: Enforce maximum relative target constraints
|
301 |
+
- **Torque Management**: Handle torque disable on disconnect
|
302 |
+
- **Error Recovery**: Graceful handling of calibration failures
|
303 |
+
|
304 |
+
#### Device Communication
|
305 |
+
|
306 |
+
- **Serial Protocol**: Match Python implementation's communication protocol
|
307 |
+
- **Timeout Handling**: Appropriate timeouts for device responses
|
308 |
+
- **Connection Validation**: Verify device is responding before calibration
|
309 |
+
|
310 |
+
#### Platform-Specific Challenges
|
311 |
+
|
312 |
+
**Node.js Platform:**
|
313 |
+
|
314 |
+
- **Port Access**: Direct system-level port access
|
315 |
+
- **Port Discovery**: Programmatic port listing via `SerialPort.list()`
|
316 |
+
- **Event Handling**: Traditional callback/event-based patterns
|
317 |
+
- **Error Handling**: System-level error codes and messages
|
318 |
+
|
319 |
+
**Web Platform:**
|
320 |
+
|
321 |
+
- **User Permission**: Requires user interaction for port selection
|
322 |
+
- **Limited Discovery**: Cannot programmatically list ports
|
323 |
+
- **Stream-Based**: Modern Promise/Stream-based patterns
|
324 |
+
- **Browser Security**: Limited to what browser security model allows
|
325 |
+
- **Configuration Storage**: Use browser storage APIs (localStorage/IndexedDB) or File System Access API
|
326 |
+
|
327 |
+
#### CLI Argument Parsing
|
328 |
+
|
329 |
+
- **Exact Matching**: Command line arguments must match Python version exactly
|
330 |
+
- **Validation**: Input validation for robot types, ports, and IDs
|
331 |
+
- **Help Text**: Identical help text and usage examples as Python version
|
332 |
+
|
333 |
+
#### Hugging Face Directory Discovery (Node.js)
|
334 |
+
|
335 |
+
```typescript
|
336 |
+
// Equivalent to Python's huggingface_hub.constants.HF_HOME
|
337 |
+
function getHfHome(): string {
|
338 |
+
if (process.env.HF_HOME) {
|
339 |
+
return process.env.HF_HOME;
|
340 |
+
}
|
341 |
+
|
342 |
+
const homeDir = os.homedir();
|
343 |
+
if (process.platform === "win32") {
|
344 |
+
return path.join(homeDir, ".cache", "huggingface");
|
345 |
+
} else {
|
346 |
+
return path.join(homeDir, ".cache", "huggingface");
|
347 |
+
}
|
348 |
+
}
|
349 |
+
|
350 |
+
// Equivalent to Python's HF_LEROBOT_CALIBRATION
|
351 |
+
function getCalibrationDir(): string {
|
352 |
+
if (process.env.HF_LEROBOT_CALIBRATION) {
|
353 |
+
return process.env.HF_LEROBOT_CALIBRATION;
|
354 |
+
}
|
355 |
+
|
356 |
+
return path.join(getHfHome(), "lerobot", "calibration");
|
357 |
+
}
|
358 |
+
```
|
359 |
+
|
360 |
+
## Definition of Done
|
361 |
+
|
362 |
+
- [x] **Functional**: Successfully calibrates SO-100 robots and teleoperators on both platforms
|
363 |
+
- [x] **CLI Compatible**: `npx lerobot calibrate` matches Python `python -m lerobot.calibrate`
|
364 |
+
- [x] **Web Compatible**: Browser-based calibration with Web Serial API
|
365 |
+
- [x] **Cross-Platform**: Node.js works on Windows, macOS, and Linux; Web works in Chromium browsers
|
366 |
+
- [x] **Tested**: Unit tests for core logic, integration tests with mock devices for both platforms
|
367 |
+
- [x] **Error Handling**: Platform-appropriate error handling and user-friendly messages
|
368 |
+
- [x] **Configuration**: Platform-appropriate configuration storage (filesystem vs browser storage)
|
369 |
+
- [x] **Type Safe**: Full TypeScript coverage with strict mode for both implementations
|
370 |
+
- [x] **Follows Conventions**: Matches Python lerobot UX/API exactly (CLI), provides intuitive web UX
|
371 |
+
- [x] **Integration**: Node.js works with ports discovered by User Story 001; Web uses browser port selection
|
docs/planning/003_demo_in_react.md
ADDED
@@ -0,0 +1,299 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# User Story 003: Interactive React Demo
|
2 |
+
|
3 |
+
## Story
|
4 |
+
|
5 |
+
**As a** potential lerobot.js user exploring the library capabilities
|
6 |
+
**I want** an interactive web demo with a modern, polished UI
|
7 |
+
**So that** I can easily test robot calibration features and understand the library's potential before integrating it into my own projects
|
8 |
+
|
9 |
+
## Background
|
10 |
+
|
11 |
+
While lerobot.js provides platform-agnostic robotics functionality, we need a compelling demo interface to showcase its web capabilities. The current development setup uses Vite with basic HTML/CSS, but lacks the polish needed to demonstrate professional robotics applications.
|
12 |
+
|
13 |
+
The Python lerobot ecosystem includes various visualization tools and demos. For lerobot.js, we need a modern web demo that:
|
14 |
+
|
15 |
+
1. Showcases the existing web calibration functionality from User Story 002
|
16 |
+
2. Provides an intuitive UI for robot setup and calibration
|
17 |
+
3. Demonstrates real-time robot interaction capabilities
|
18 |
+
4. Serves as a reference implementation for web integration
|
19 |
+
|
20 |
+
**Critical Requirement**: React, Tailwind, and shadcn/ui should be **development dependencies only** - the core lerobot library must remain framework-agnostic so users can integrate it with any frontend framework or vanilla JavaScript.
|
21 |
+
|
22 |
+
## Acceptance Criteria
|
23 |
+
|
24 |
+
### Core Demo Features
|
25 |
+
|
26 |
+
- [ ] **Robot Calibration Interface**: Modern UI for SO-100 follower/leader calibration
|
27 |
+
- [ ] **Port Selection**: Intuitive Web Serial API port selection with visual feedback
|
28 |
+
- [ ] **Calibration Progress**: Real-time progress indicators during calibration procedures
|
29 |
+
- [ ] **Configuration Display**: View and manage saved calibration configurations
|
30 |
+
- [ ] **Error Handling**: User-friendly error messages and recovery suggestions
|
31 |
+
- [ ] **Responsive Design**: Works on desktop, tablet, and mobile devices
|
32 |
+
|
33 |
+
### UI/UX Requirements
|
34 |
+
|
35 |
+
- [ ] **Modern Design**: Clean, professional interface using Tailwind 4 and shadcn/ui
|
36 |
+
- [ ] **Brand Consistency**: Consistent with Hugging Face design language
|
37 |
+
- [ ] **Accessibility**: WCAG 2.1 AA compliant interface
|
38 |
+
- [ ] **Dark/Light Mode**: Theme switching support
|
39 |
+
- [ ] **Loading States**: Smooth loading and transition animations
|
40 |
+
- [ ] **Visual Feedback**: Clear status indicators for connection, calibration, and errors
|
41 |
+
|
42 |
+
### Technical Requirements
|
43 |
+
|
44 |
+
- [ ] **Framework Isolation**: React used only for demo, core library remains framework-agnostic
|
45 |
+
- [ ] **Development Only**: React/Tailwind/shadcn as devDependencies, not regular dependencies
|
46 |
+
- [ ] **Vite Integration**: Seamless integration with existing Vite development setup
|
47 |
+
- [ ] **TypeScript**: Full type safety throughout React components
|
48 |
+
- [ ] **Build Separation**: Demo build separate from library build
|
49 |
+
- [ ] **Tree Shaking**: Demo dependencies excluded from library builds
|
50 |
+
|
51 |
+
### Library Integration
|
52 |
+
|
53 |
+
- [ ] **Web API Usage**: Demonstrates proper usage of lerobot web APIs
|
54 |
+
- [ ] **Error Boundaries**: Robust error handling that doesn't break the demo
|
55 |
+
- [ ] **Performance**: Smooth interaction without blocking the UI thread
|
56 |
+
- [ ] **Real Hardware**: Demo works with actual SO-100 hardware via Web Serial API
|
57 |
+
|
58 |
+
## Expected User Flow
|
59 |
+
|
60 |
+
### Development Experience
|
61 |
+
|
62 |
+
```bash
|
63 |
+
# Install demo dependencies (includes React, Tailwind, shadcn/ui as devDependencies)
|
64 |
+
$ pnpm install
|
65 |
+
|
66 |
+
# Start development server with React demo
|
67 |
+
$ pnpm run dev
|
68 |
+
# Opens modern React interface at http://localhost:5173
|
69 |
+
```
|
70 |
+
|
71 |
+
### Demo Interface Flow
|
72 |
+
|
73 |
+
1. **Landing Page**: Clean introduction to lerobot.js with call-to-action buttons
|
74 |
+
2. **Robot Setup**: Card-based interface for selecting robot type (SO-100 follower/leader)
|
75 |
+
3. **Port Connection**:
|
76 |
+
- Click "Connect Robot" button
|
77 |
+
- Browser shows Web Serial API port selection dialog
|
78 |
+
- Visual feedback shows connection status
|
79 |
+
4. **Calibration Interface**:
|
80 |
+
- Step-by-step calibration wizard
|
81 |
+
- Progress indicators and instructions
|
82 |
+
- Real-time motor position feedback (if applicable)
|
83 |
+
5. **Results Display**:
|
84 |
+
- Success confirmation with visual feedback
|
85 |
+
- Option to download configuration file
|
86 |
+
- Suggestions for next steps
|
87 |
+
|
88 |
+
### Error Handling Flow
|
89 |
+
|
90 |
+
- **No Web Serial Support**: Clear message with browser compatibility info
|
91 |
+
- **Connection Failed**: Troubleshooting steps with visual aids
|
92 |
+
- **Calibration Errors**: Descriptive error messages with retry options
|
93 |
+
- **Permission Denied**: Guide user through browser permission setup
|
94 |
+
|
95 |
+
## Implementation Details
|
96 |
+
|
97 |
+
### Project Structure Changes
|
98 |
+
|
99 |
+
```
|
100 |
+
lerobot.js/
|
101 |
+
βββ src/
|
102 |
+
β βββ demo/ # Demo-specific React components (new)
|
103 |
+
β β βββ components/
|
104 |
+
β β β βββ ui/ # shadcn/ui components
|
105 |
+
β β β βββ CalibrationWizard.tsx
|
106 |
+
β β β βββ RobotCard.tsx
|
107 |
+
β β β βββ ConnectionStatus.tsx
|
108 |
+
β β β βββ ErrorBoundary.tsx
|
109 |
+
β β βββ pages/
|
110 |
+
β β β βββ Home.tsx
|
111 |
+
β β β βββ Setup.tsx
|
112 |
+
β β β βββ Calibrate.tsx
|
113 |
+
β β βββ hooks/
|
114 |
+
β β β βββ useRobotConnection.ts
|
115 |
+
β β β βββ useCalibration.ts
|
116 |
+
β β βββ App.tsx
|
117 |
+
β β βββ main.tsx
|
118 |
+
β βββ lerobot/ # Core library (unchanged)
|
119 |
+
β β βββ web/
|
120 |
+
β β βββ node/
|
121 |
+
β βββ main.ts # Original Vite entry (unchanged)
|
122 |
+
βββ index.html # Updated to load React demo
|
123 |
+
βββ demo.html # New: Vanilla JS demo option
|
124 |
+
βββ lib.html # New: Library-only demo
|
125 |
+
```
|
126 |
+
|
127 |
+
### Package.json Changes
|
128 |
+
|
129 |
+
```json
|
130 |
+
{
|
131 |
+
"scripts": {
|
132 |
+
"dev": "vite --mode demo", // Runs React demo
|
133 |
+
"dev:vanilla": "vite --mode vanilla", // Runs vanilla demo
|
134 |
+
"dev:lib": "vite --mode lib", // Library-only mode
|
135 |
+
"build": "tsc && vite build --mode lib", // Library build (no React)
|
136 |
+
"build:demo": "tsc && vite build --mode demo" // Demo build (with React)
|
137 |
+
},
|
138 |
+
"devDependencies": {
|
139 |
+
"react": "^18.2.0",
|
140 |
+
"react-dom": "^18.2.0",
|
141 |
+
"@types/react": "^18.2.0",
|
142 |
+
"@types/react-dom": "^18.2.0",
|
143 |
+
"tailwindcss": "^4.0.0",
|
144 |
+
"@tailwindcss/typography": "^0.5.0",
|
145 |
+
"autoprefixer": "^10.4.0",
|
146 |
+
"postcss": "^8.4.0",
|
147 |
+
"class-variance-authority": "^0.7.0",
|
148 |
+
"clsx": "^2.0.0",
|
149 |
+
"tailwind-merge": "^2.0.0",
|
150 |
+
"lucide-react": "^0.400.0"
|
151 |
+
}
|
152 |
+
}
|
153 |
+
```
|
154 |
+
|
155 |
+
### Vite Configuration
|
156 |
+
|
157 |
+
```typescript
|
158 |
+
// vite.config.ts
|
159 |
+
export default defineConfig(({ mode }) => {
|
160 |
+
const baseConfig = {
|
161 |
+
plugins: [typescript()],
|
162 |
+
build: {
|
163 |
+
lib: {
|
164 |
+
entry: "src/main.ts",
|
165 |
+
name: "LeRobot",
|
166 |
+
fileName: "lerobot",
|
167 |
+
},
|
168 |
+
},
|
169 |
+
};
|
170 |
+
|
171 |
+
if (mode === "demo") {
|
172 |
+
return {
|
173 |
+
...baseConfig,
|
174 |
+
plugins: [...baseConfig.plugins, react()],
|
175 |
+
css: {
|
176 |
+
postcss: {
|
177 |
+
plugins: [tailwindcss, autoprefixer],
|
178 |
+
},
|
179 |
+
},
|
180 |
+
build: {
|
181 |
+
// Demo-specific build configuration
|
182 |
+
outDir: "dist/demo",
|
183 |
+
rollupOptions: {
|
184 |
+
input: {
|
185 |
+
main: "index.html",
|
186 |
+
},
|
187 |
+
},
|
188 |
+
},
|
189 |
+
};
|
190 |
+
}
|
191 |
+
|
192 |
+
return baseConfig; // Library-only build
|
193 |
+
});
|
194 |
+
```
|
195 |
+
|
196 |
+
### Key Dependencies
|
197 |
+
|
198 |
+
#### Demo-Only Dependencies (devDependencies)
|
199 |
+
|
200 |
+
- **React 18**: Latest stable React with concurrent features
|
201 |
+
- **Tailwind CSS 4**: Latest Tailwind with modern CSS features
|
202 |
+
- **shadcn/ui**: High-quality React component library
|
203 |
+
- **Lucide React**: Modern icon library
|
204 |
+
- **class-variance-authority**: For component variant management
|
205 |
+
- **clsx + tailwind-merge**: For conditional class management
|
206 |
+
|
207 |
+
#### Build Tools
|
208 |
+
|
209 |
+
- **@vitejs/plugin-react**: React support for Vite
|
210 |
+
- **PostCSS**: CSS processing for Tailwind
|
211 |
+
- **Autoprefixer**: CSS vendor prefixing
|
212 |
+
|
213 |
+
### React Components Architecture
|
214 |
+
|
215 |
+
#### Core Demo Components
|
216 |
+
|
217 |
+
```typescript
|
218 |
+
// Demo-specific React hooks
|
219 |
+
function useRobotConnection() {
|
220 |
+
// Wraps lerobot web APIs in React-friendly hooks
|
221 |
+
// Manages connection state, error handling
|
222 |
+
}
|
223 |
+
|
224 |
+
function useCalibration() {
|
225 |
+
// Wraps lerobot calibration APIs
|
226 |
+
// Provides progress tracking, status updates
|
227 |
+
}
|
228 |
+
|
229 |
+
// Main calibration wizard component
|
230 |
+
function CalibrationWizard({ robotType }: { robotType: string }) {
|
231 |
+
const { connect, disconnect, status } = useRobotConnection();
|
232 |
+
const { calibrate, progress, error } = useCalibration();
|
233 |
+
|
234 |
+
// Multi-step wizard UI using shadcn/ui components
|
235 |
+
}
|
236 |
+
```
|
237 |
+
|
238 |
+
#### shadcn/ui Integration
|
239 |
+
|
240 |
+
- **Button**: Primary actions (Connect, Calibrate, Retry)
|
241 |
+
- **Card**: Robot selection, status displays, results
|
242 |
+
- **Progress**: Calibration progress indicators
|
243 |
+
- **Alert**: Error messages and warnings
|
244 |
+
- **Badge**: Status indicators (Connected, Calibrating, Error)
|
245 |
+
- **Dialog**: Confirmation dialogs and detailed error information
|
246 |
+
- **Toast**: Success/error notifications
|
247 |
+
|
248 |
+
### Technical Considerations
|
249 |
+
|
250 |
+
#### Framework Isolation Strategy
|
251 |
+
|
252 |
+
1. **Separate Entry Points**: Demo uses React, library uses vanilla TypeScript
|
253 |
+
2. **Build Modes**: Vite modes for demo vs library builds
|
254 |
+
3. **Dependency Isolation**: React in devDependencies, excluded from library bundle
|
255 |
+
4. **Type Safety**: Shared types between demo and library, but no runtime dependencies
|
256 |
+
|
257 |
+
#### Tailwind 4 Integration
|
258 |
+
|
259 |
+
- **New CSS Engine**: Leverage Tailwind 4's improved performance
|
260 |
+
- **Container Queries**: Responsive components using container queries
|
261 |
+
- **Modern CSS**: CSS Grid, flexbox, custom properties integration
|
262 |
+
- **Optimization**: Automatic unused CSS elimination
|
263 |
+
|
264 |
+
#### Performance Considerations
|
265 |
+
|
266 |
+
- **Code Splitting**: Lazy load calibration components
|
267 |
+
- **Bundle Size**: Demo bundle separate from library bundle
|
268 |
+
- **Web Serial API**: Non-blocking serial communication
|
269 |
+
- **Error Boundaries**: Prevent component crashes from breaking entire demo
|
270 |
+
|
271 |
+
#### Accessibility
|
272 |
+
|
273 |
+
- **Keyboard Navigation**: Full keyboard support for all interactions
|
274 |
+
- **Screen Readers**: Proper ARIA labels and descriptions
|
275 |
+
- **Color Contrast**: WCAG AA compliant color schemes
|
276 |
+
- **Focus Management**: Proper focus handling during async operations
|
277 |
+
|
278 |
+
## Testing Strategy
|
279 |
+
|
280 |
+
### Integration Testing
|
281 |
+
|
282 |
+
- **Library Integration**: Verify demo correctly uses lerobot APIs
|
283 |
+
- **Build Testing**: Ensure library builds don't include React
|
284 |
+
- **Browser Compatibility**: Test Web Serial API across supported browsers
|
285 |
+
|
286 |
+
## Definition of Done
|
287 |
+
|
288 |
+
- [ ] **Functional Demo**: Interactive React demo showcases robot calibration
|
289 |
+
- [ ] **Modern UI**: Professional interface using Tailwind 4 and shadcn/ui
|
290 |
+
- [ ] **Framework Isolation**: React isolated to demo only, library remains framework-agnostic
|
291 |
+
- [ ] **Build Separation**: Library builds exclude React dependencies
|
292 |
+
- [ ] **Browser Compatibility**: Works in Chrome, Edge, and other Chromium browsers
|
293 |
+
- [ ] **Responsive Design**: Works across desktop, tablet, and mobile devices
|
294 |
+
- [ ] **Accessibility**: WCAG 2.1 AA compliant interface
|
295 |
+
- [ ] **Error Handling**: Graceful error handling with user-friendly messages
|
296 |
+
- [ ] **Documentation**: Demo usage documented in README
|
297 |
+
- [ ] **Performance**: Smooth interactions, fast loading times
|
298 |
+
- [ ] **Type Safety**: Full TypeScript coverage for demo components
|
299 |
+
- [ ] **Real Hardware**: Successfully calibrates actual SO-100 hardware via Web Serial API
|
package.json
CHANGED
@@ -25,16 +25,19 @@
|
|
25 |
"build:web": "tsc && vite build",
|
26 |
"preview": "vite preview",
|
27 |
"cli:find-port": "tsx src/cli/index.ts find-port",
|
28 |
-
"
|
|
|
|
|
29 |
},
|
30 |
"dependencies": {
|
|
|
31 |
"serialport": "^12.0.0"
|
32 |
},
|
33 |
"devDependencies": {
|
34 |
-
"
|
35 |
-
"vite": "^6.3.5",
|
36 |
"tsx": "^4.19.2",
|
37 |
-
"
|
|
|
38 |
},
|
39 |
"repository": {
|
40 |
"type": "git",
|
|
|
25 |
"build:web": "tsc && vite build",
|
26 |
"preview": "vite preview",
|
27 |
"cli:find-port": "tsx src/cli/index.ts find-port",
|
28 |
+
"cli:calibrate": "tsx src/cli/index.ts calibrate",
|
29 |
+
"prepublishOnly": "pnpm run build",
|
30 |
+
"install-global": "pnpm run build && npm link"
|
31 |
},
|
32 |
"dependencies": {
|
33 |
+
"log-update": "^6.1.0",
|
34 |
"serialport": "^12.0.0"
|
35 |
},
|
36 |
"devDependencies": {
|
37 |
+
"@types/node": "^22.10.5",
|
|
|
38 |
"tsx": "^4.19.2",
|
39 |
+
"typescript": "~5.8.3",
|
40 |
+
"vite": "^6.3.5"
|
41 |
},
|
42 |
"repository": {
|
43 |
"type": "git",
|
pnpm-lock.yaml
CHANGED
@@ -8,6 +8,9 @@ importers:
|
|
8 |
|
9 |
.:
|
10 |
dependencies:
|
|
|
|
|
|
|
11 |
serialport:
|
12 |
specifier: ^12.0.0
|
13 |
version: 12.0.0
|
@@ -347,6 +350,22 @@ packages:
|
|
347 |
'@types/[email protected]':
|
348 |
resolution: {integrity: sha512-jnVe5ULKl6tijxUhvQeNbQG/84fHfg+yMak02cT8QVhBx/F05rAVxCGBYYTh2EKz22D6JF5ktXuNwdx7b9iEGw==}
|
349 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
350 | |
351 |
resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
|
352 |
engines: {node: '>=6.0'}
|
@@ -356,6 +375,13 @@ packages:
|
|
356 |
supports-color:
|
357 |
optional: true
|
358 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
359 | |
360 |
resolution: {integrity: sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==}
|
361 |
engines: {node: '>=18'}
|
@@ -374,9 +400,25 @@ packages:
|
|
374 |
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
375 |
os: [darwin]
|
376 |
|
|
|
|
|
|
|
|
|
377 | |
378 |
resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==}
|
379 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
380 | |
381 |
resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
|
382 |
|
@@ -392,6 +434,10 @@ packages:
|
|
392 |
resolution: {integrity: sha512-NTZVKn9IylLwUzaKjkas1e4u2DLNcV4rdYagA4PWdPwW87Bi7z+BznyKSRwS/761tV/lzCGXplWsiaMjLqP2zQ==}
|
393 |
hasBin: true
|
394 |
|
|
|
|
|
|
|
|
|
395 | |
396 |
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
397 |
|
@@ -406,6 +452,10 @@ packages:
|
|
406 | |
407 |
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
408 |
|
|
|
|
|
|
|
|
|
409 | |
410 |
resolution: {integrity: sha512-wdN2Kd3Twh8MAEOEJZsuxuLKCsBEo4PVNLK6tQWAn10VhsVewQLzcucMgLolRlhFybGxfclbPeEYBaP6RvUFGg==}
|
411 |
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
@@ -415,10 +465,26 @@ packages:
|
|
415 |
resolution: {integrity: sha512-AmH3D9hHPFmnF/oq/rvigfiAouAKyK/TjnrkwZRYSFZxNggJxwvbAbfYrLeuvq7ktUdhuHdVdSjj852Z55R+uA==}
|
416 |
engines: {node: '>=16.0.0'}
|
417 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
418 | |
419 |
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
420 |
engines: {node: '>=0.10.0'}
|
421 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
422 | |
423 |
resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==}
|
424 |
engines: {node: '>=12.0.0'}
|
@@ -476,6 +542,10 @@ packages:
|
|
476 |
yaml:
|
477 |
optional: true
|
478 |
|
|
|
|
|
|
|
|
|
479 |
snapshots:
|
480 |
|
481 |
'@esbuild/[email protected]':
|
@@ -673,10 +743,26 @@ snapshots:
|
|
673 |
dependencies:
|
674 |
undici-types: 6.21.0
|
675 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
676 | |
677 |
dependencies:
|
678 |
ms: 2.1.2
|
679 |
|
|
|
|
|
|
|
|
|
680 | |
681 |
optionalDependencies:
|
682 |
'@esbuild/aix-ppc64': 0.25.5
|
@@ -712,10 +798,26 @@ snapshots:
|
|
712 | |
713 |
optional: true
|
714 |
|
|
|
|
|
715 | |
716 |
dependencies:
|
717 |
resolve-pkg-maps: 1.0.0
|
718 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
719 | |
720 |
|
721 | |
@@ -724,6 +826,10 @@ snapshots:
|
|
724 |
|
725 | |
726 |
|
|
|
|
|
|
|
|
|
727 | |
728 |
|
729 | |
@@ -736,6 +842,11 @@ snapshots:
|
|
736 |
|
737 | |
738 |
|
|
|
|
|
|
|
|
|
|
|
739 | |
740 |
dependencies:
|
741 |
'@types/estree': 1.0.7
|
@@ -781,8 +892,25 @@ snapshots:
|
|
781 |
transitivePeerDependencies:
|
782 |
- supports-color
|
783 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
784 | |
785 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
786 | |
787 |
dependencies:
|
788 |
fdir: 6.4.6([email protected])
|
@@ -811,3 +939,9 @@ snapshots:
|
|
811 |
'@types/node': 22.15.31
|
812 |
fsevents: 2.3.3
|
813 |
tsx: 4.20.3
|
|
|
|
|
|
|
|
|
|
|
|
|
|
8 |
|
9 |
.:
|
10 |
dependencies:
|
11 |
+
log-update:
|
12 |
+
specifier: ^6.1.0
|
13 |
+
version: 6.1.0
|
14 |
serialport:
|
15 |
specifier: ^12.0.0
|
16 |
version: 12.0.0
|
|
|
350 |
'@types/[email protected]':
|
351 |
resolution: {integrity: sha512-jnVe5ULKl6tijxUhvQeNbQG/84fHfg+yMak02cT8QVhBx/F05rAVxCGBYYTh2EKz22D6JF5ktXuNwdx7b9iEGw==}
|
352 |
|
353 | |
354 |
+
resolution: {integrity: sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==}
|
355 |
+
engines: {node: '>=18'}
|
356 |
+
|
357 | |
358 |
+
resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==}
|
359 |
+
engines: {node: '>=12'}
|
360 |
+
|
361 | |
362 |
+
resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==}
|
363 |
+
engines: {node: '>=12'}
|
364 |
+
|
365 | |
366 |
+
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
|
367 |
+
engines: {node: '>=18'}
|
368 |
+
|
369 | |
370 |
resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
|
371 |
engines: {node: '>=6.0'}
|
|
|
375 |
supports-color:
|
376 |
optional: true
|
377 |
|
378 | |
379 |
+
resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==}
|
380 |
+
|
381 | |
382 |
+
resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==}
|
383 |
+
engines: {node: '>=18'}
|
384 |
+
|
385 | |
386 |
resolution: {integrity: sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==}
|
387 |
engines: {node: '>=18'}
|
|
|
400 |
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
401 |
os: [darwin]
|
402 |
|
403 | |
404 |
+
resolution: {integrity: sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==}
|
405 |
+
engines: {node: '>=18'}
|
406 |
+
|
407 | |
408 |
resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==}
|
409 |
|
410 | |
411 |
+
resolution: {integrity: sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==}
|
412 |
+
engines: {node: '>=18'}
|
413 |
+
|
414 | |
415 |
+
resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==}
|
416 |
+
engines: {node: '>=18'}
|
417 |
+
|
418 | |
419 |
+
resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
|
420 |
+
engines: {node: '>=18'}
|
421 |
+
|
422 | |
423 |
resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
|
424 |
|
|
|
434 |
resolution: {integrity: sha512-NTZVKn9IylLwUzaKjkas1e4u2DLNcV4rdYagA4PWdPwW87Bi7z+BznyKSRwS/761tV/lzCGXplWsiaMjLqP2zQ==}
|
435 |
hasBin: true
|
436 |
|
437 | |
438 |
+
resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==}
|
439 |
+
engines: {node: '>=18'}
|
440 |
+
|
441 | |
442 |
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
443 |
|
|
|
452 | |
453 |
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
454 |
|
455 | |
456 |
+
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
|
457 |
+
engines: {node: '>=18'}
|
458 |
+
|
459 | |
460 |
resolution: {integrity: sha512-wdN2Kd3Twh8MAEOEJZsuxuLKCsBEo4PVNLK6tQWAn10VhsVewQLzcucMgLolRlhFybGxfclbPeEYBaP6RvUFGg==}
|
461 |
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
|
|
465 |
resolution: {integrity: sha512-AmH3D9hHPFmnF/oq/rvigfiAouAKyK/TjnrkwZRYSFZxNggJxwvbAbfYrLeuvq7ktUdhuHdVdSjj852Z55R+uA==}
|
466 |
engines: {node: '>=16.0.0'}
|
467 |
|
468 | |
469 |
+
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
470 |
+
engines: {node: '>=14'}
|
471 |
+
|
472 | |
473 |
+
resolution: {integrity: sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==}
|
474 |
+
engines: {node: '>=18'}
|
475 |
+
|
476 | |
477 |
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
478 |
engines: {node: '>=0.10.0'}
|
479 |
|
480 | |
481 |
+
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
|
482 |
+
engines: {node: '>=18'}
|
483 |
+
|
484 | |
485 |
+
resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==}
|
486 |
+
engines: {node: '>=12'}
|
487 |
+
|
488 | |
489 |
resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==}
|
490 |
engines: {node: '>=12.0.0'}
|
|
|
542 |
yaml:
|
543 |
optional: true
|
544 |
|
545 | |
546 |
+
resolution: {integrity: sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==}
|
547 |
+
engines: {node: '>=18'}
|
548 |
+
|
549 |
snapshots:
|
550 |
|
551 |
'@esbuild/[email protected]':
|
|
|
743 |
dependencies:
|
744 |
undici-types: 6.21.0
|
745 |
|
746 | |
747 |
+
dependencies:
|
748 |
+
environment: 1.1.0
|
749 |
+
|
750 |
+
[email protected]: {}
|
751 |
+
|
752 |
+
[email protected]: {}
|
753 |
+
|
754 | |
755 |
+
dependencies:
|
756 |
+
restore-cursor: 5.1.0
|
757 |
+
|
758 | |
759 |
dependencies:
|
760 |
ms: 2.1.2
|
761 |
|
762 |
+
[email protected]: {}
|
763 |
+
|
764 |
+
[email protected]: {}
|
765 |
+
|
766 | |
767 |
optionalDependencies:
|
768 |
'@esbuild/aix-ppc64': 0.25.5
|
|
|
798 | |
799 |
optional: true
|
800 |
|
801 |
+
[email protected]: {}
|
802 |
+
|
803 | |
804 |
dependencies:
|
805 |
resolve-pkg-maps: 1.0.0
|
806 |
|
807 | |
808 |
+
dependencies:
|
809 |
+
get-east-asian-width: 1.3.0
|
810 |
+
|
811 | |
812 |
+
dependencies:
|
813 |
+
ansi-escapes: 7.0.0
|
814 |
+
cli-cursor: 5.0.0
|
815 |
+
slice-ansi: 7.1.0
|
816 |
+
strip-ansi: 7.1.0
|
817 |
+
wrap-ansi: 9.0.0
|
818 |
+
|
819 |
+
[email protected]: {}
|
820 |
+
|
821 | |
822 |
|
823 | |
|
|
826 |
|
827 | |
828 |
|
829 | |
830 |
+
dependencies:
|
831 |
+
mimic-function: 5.0.1
|
832 |
+
|
833 | |
834 |
|
835 | |
|
|
842 |
|
843 | |
844 |
|
845 | |
846 |
+
dependencies:
|
847 |
+
onetime: 7.0.0
|
848 |
+
signal-exit: 4.1.0
|
849 |
+
|
850 | |
851 |
dependencies:
|
852 |
'@types/estree': 1.0.7
|
|
|
892 |
transitivePeerDependencies:
|
893 |
- supports-color
|
894 |
|
895 |
+
[email protected]: {}
|
896 |
+
|
897 | |
898 |
+
dependencies:
|
899 |
+
ansi-styles: 6.2.1
|
900 |
+
is-fullwidth-code-point: 5.0.0
|
901 |
+
|
902 | |
903 |
|
904 | |
905 |
+
dependencies:
|
906 |
+
emoji-regex: 10.4.0
|
907 |
+
get-east-asian-width: 1.3.0
|
908 |
+
strip-ansi: 7.1.0
|
909 |
+
|
910 | |
911 |
+
dependencies:
|
912 |
+
ansi-regex: 6.1.0
|
913 |
+
|
914 | |
915 |
dependencies:
|
916 |
fdir: 6.4.6([email protected])
|
|
|
939 |
'@types/node': 22.15.31
|
940 |
fsevents: 2.3.3
|
941 |
tsx: 4.20.3
|
942 |
+
|
943 | |
944 |
+
dependencies:
|
945 |
+
ansi-styles: 6.2.1
|
946 |
+
string-width: 7.2.0
|
947 |
+
strip-ansi: 7.1.0
|
src/cli/index.ts
CHANGED
@@ -8,6 +8,7 @@
|
|
8 |
*/
|
9 |
|
10 |
import { findPort } from "../lerobot/node/find_port.js";
|
|
|
11 |
|
12 |
/**
|
13 |
* Show usage information
|
@@ -19,9 +20,13 @@ function showUsage() {
|
|
19 |
console.log(
|
20 |
" find-port Find the USB port associated with your MotorsBus"
|
21 |
);
|
|
|
22 |
console.log("");
|
23 |
console.log("Examples:");
|
24 |
console.log(" lerobot find-port");
|
|
|
|
|
|
|
25 |
console.log("");
|
26 |
}
|
27 |
|
@@ -44,6 +49,12 @@ async function main() {
|
|
44 |
await findPort();
|
45 |
break;
|
46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
47 |
case "help":
|
48 |
case "--help":
|
49 |
case "-h":
|
|
|
8 |
*/
|
9 |
|
10 |
import { findPort } from "../lerobot/node/find_port.js";
|
11 |
+
import { main as calibrateMain } from "../lerobot/node/calibrate.js";
|
12 |
|
13 |
/**
|
14 |
* Show usage information
|
|
|
20 |
console.log(
|
21 |
" find-port Find the USB port associated with your MotorsBus"
|
22 |
);
|
23 |
+
console.log(" calibrate Recalibrate your device (robot or teleoperator)");
|
24 |
console.log("");
|
25 |
console.log("Examples:");
|
26 |
console.log(" lerobot find-port");
|
27 |
+
console.log(
|
28 |
+
" lerobot calibrate --robot.type=so100_follower --robot.port=COM4 --robot.id=my_follower_arm"
|
29 |
+
);
|
30 |
console.log("");
|
31 |
}
|
32 |
|
|
|
49 |
await findPort();
|
50 |
break;
|
51 |
|
52 |
+
case "calibrate":
|
53 |
+
// Pass remaining arguments to calibrate command
|
54 |
+
const calibrateArgs = args.slice(1);
|
55 |
+
await calibrateMain(calibrateArgs);
|
56 |
+
break;
|
57 |
+
|
58 |
case "help":
|
59 |
case "--help":
|
60 |
case "-h":
|
src/lerobot/node/calibrate.ts
ADDED
@@ -0,0 +1,248 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Helper to recalibrate your device (robot or teleoperator).
|
3 |
+
*
|
4 |
+
* Direct port of Python lerobot calibrate.py
|
5 |
+
*
|
6 |
+
* Example:
|
7 |
+
* ```
|
8 |
+
* npx lerobot calibrate --robot.type=so100_follower --robot.port=COM4 --robot.id=my_follower_arm
|
9 |
+
* ```
|
10 |
+
*/
|
11 |
+
|
12 |
+
import type { CalibrateConfig } from "./robots/config.js";
|
13 |
+
import { createSO100Follower } from "./robots/so100_follower.js";
|
14 |
+
import { createSO100Leader } from "./teleoperators/so100_leader.js";
|
15 |
+
import {
|
16 |
+
initializeDeviceCommunication,
|
17 |
+
readMotorPositions,
|
18 |
+
performInteractiveCalibration,
|
19 |
+
setMotorLimits,
|
20 |
+
verifyCalibration,
|
21 |
+
type CalibrationResults,
|
22 |
+
} from "./common/calibration.js";
|
23 |
+
import { getSO100Config } from "./common/so100_config.js";
|
24 |
+
|
25 |
+
/**
|
26 |
+
* Main calibrate function
|
27 |
+
* Mirrors Python lerobot calibrate.py calibrate() function
|
28 |
+
* Uses shared calibration procedures instead of device-specific implementations
|
29 |
+
*/
|
30 |
+
export async function calibrate(config: CalibrateConfig): Promise<void> {
|
31 |
+
// Validate configuration - exactly one device must be specified
|
32 |
+
if (Boolean(config.robot) === Boolean(config.teleop)) {
|
33 |
+
throw new Error("Choose either a robot or a teleop.");
|
34 |
+
}
|
35 |
+
|
36 |
+
const deviceConfig = config.robot || config.teleop!;
|
37 |
+
|
38 |
+
let device;
|
39 |
+
let calibrationResults: CalibrationResults;
|
40 |
+
|
41 |
+
try {
|
42 |
+
// Create device for connection management only
|
43 |
+
if (config.robot) {
|
44 |
+
switch (config.robot.type) {
|
45 |
+
case "so100_follower":
|
46 |
+
device = createSO100Follower(config.robot);
|
47 |
+
break;
|
48 |
+
default:
|
49 |
+
throw new Error(`Unsupported robot type: ${config.robot.type}`);
|
50 |
+
}
|
51 |
+
} else if (config.teleop) {
|
52 |
+
switch (config.teleop.type) {
|
53 |
+
case "so100_leader":
|
54 |
+
device = createSO100Leader(config.teleop);
|
55 |
+
break;
|
56 |
+
default:
|
57 |
+
throw new Error(
|
58 |
+
`Unsupported teleoperator type: ${config.teleop.type}`
|
59 |
+
);
|
60 |
+
}
|
61 |
+
}
|
62 |
+
|
63 |
+
if (!device) {
|
64 |
+
throw new Error("Failed to create device");
|
65 |
+
}
|
66 |
+
|
67 |
+
// Connect to device (silent unless error)
|
68 |
+
await device.connect(false); // calibrate=False like Python
|
69 |
+
|
70 |
+
// Get SO-100 calibration configuration
|
71 |
+
const so100Config = getSO100Config(
|
72 |
+
deviceConfig.type as "so100_follower" | "so100_leader",
|
73 |
+
(device as any).port
|
74 |
+
);
|
75 |
+
|
76 |
+
// Perform shared calibration procedures (silent unless error)
|
77 |
+
await initializeDeviceCommunication(so100Config);
|
78 |
+
await setMotorLimits(so100Config);
|
79 |
+
|
80 |
+
// Interactive calibration with live updates - THE MAIN PART
|
81 |
+
calibrationResults = await performInteractiveCalibration(so100Config);
|
82 |
+
|
83 |
+
// Save and cleanup (silent unless error)
|
84 |
+
await verifyCalibration(so100Config);
|
85 |
+
await (device as any).saveCalibration(calibrationResults);
|
86 |
+
await device.disconnect();
|
87 |
+
} catch (error) {
|
88 |
+
// Ensure we disconnect even if there's an error
|
89 |
+
if (device) {
|
90 |
+
try {
|
91 |
+
await device.disconnect();
|
92 |
+
} catch (disconnectError) {
|
93 |
+
console.warn("Warning: Failed to disconnect properly");
|
94 |
+
}
|
95 |
+
}
|
96 |
+
throw error;
|
97 |
+
}
|
98 |
+
}
|
99 |
+
|
100 |
+
/**
|
101 |
+
* Parse command line arguments in Python argparse style
|
102 |
+
* Handles --robot.type=so100_follower --robot.port=COM4 format
|
103 |
+
*/
|
104 |
+
export function parseArgs(args: string[]): CalibrateConfig {
|
105 |
+
const config: CalibrateConfig = {};
|
106 |
+
|
107 |
+
for (const arg of args) {
|
108 |
+
if (arg.startsWith("--robot.")) {
|
109 |
+
if (!config.robot) {
|
110 |
+
config.robot = { type: "so100_follower", port: "" };
|
111 |
+
}
|
112 |
+
|
113 |
+
const [key, value] = arg.substring(8).split("=");
|
114 |
+
switch (key) {
|
115 |
+
case "type":
|
116 |
+
if (value !== "so100_follower") {
|
117 |
+
throw new Error(`Unsupported robot type: ${value}`);
|
118 |
+
}
|
119 |
+
config.robot.type = value as "so100_follower";
|
120 |
+
break;
|
121 |
+
case "port":
|
122 |
+
config.robot.port = value;
|
123 |
+
break;
|
124 |
+
case "id":
|
125 |
+
config.robot.id = value;
|
126 |
+
break;
|
127 |
+
case "disable_torque_on_disconnect":
|
128 |
+
config.robot.disable_torque_on_disconnect = value === "true";
|
129 |
+
break;
|
130 |
+
case "max_relative_target":
|
131 |
+
config.robot.max_relative_target = value ? parseInt(value) : null;
|
132 |
+
break;
|
133 |
+
case "use_degrees":
|
134 |
+
config.robot.use_degrees = value === "true";
|
135 |
+
break;
|
136 |
+
default:
|
137 |
+
throw new Error(`Unknown robot parameter: ${key}`);
|
138 |
+
}
|
139 |
+
} else if (arg.startsWith("--teleop.")) {
|
140 |
+
if (!config.teleop) {
|
141 |
+
config.teleop = { type: "so100_leader", port: "" };
|
142 |
+
}
|
143 |
+
|
144 |
+
const [key, value] = arg.substring(9).split("=");
|
145 |
+
switch (key) {
|
146 |
+
case "type":
|
147 |
+
if (value !== "so100_leader") {
|
148 |
+
throw new Error(`Unsupported teleoperator type: ${value}`);
|
149 |
+
}
|
150 |
+
config.teleop.type = value as "so100_leader";
|
151 |
+
break;
|
152 |
+
case "port":
|
153 |
+
config.teleop.port = value;
|
154 |
+
break;
|
155 |
+
case "id":
|
156 |
+
config.teleop.id = value;
|
157 |
+
break;
|
158 |
+
default:
|
159 |
+
throw new Error(`Unknown teleoperator parameter: ${key}`);
|
160 |
+
}
|
161 |
+
} else if (arg === "--help" || arg === "-h") {
|
162 |
+
showUsage();
|
163 |
+
process.exit(0);
|
164 |
+
} else if (!arg.startsWith("--")) {
|
165 |
+
// Skip non-option arguments
|
166 |
+
continue;
|
167 |
+
} else {
|
168 |
+
throw new Error(`Unknown argument: ${arg}`);
|
169 |
+
}
|
170 |
+
}
|
171 |
+
|
172 |
+
// Validate required fields
|
173 |
+
if (config.robot && !config.robot.port) {
|
174 |
+
throw new Error("Robot port is required (--robot.port=PORT)");
|
175 |
+
}
|
176 |
+
if (config.teleop && !config.teleop.port) {
|
177 |
+
throw new Error("Teleoperator port is required (--teleop.port=PORT)");
|
178 |
+
}
|
179 |
+
|
180 |
+
return config;
|
181 |
+
}
|
182 |
+
|
183 |
+
/**
|
184 |
+
* Show usage information matching Python argparse output
|
185 |
+
*/
|
186 |
+
function showUsage(): void {
|
187 |
+
console.log("Usage: lerobot calibrate [options]");
|
188 |
+
console.log("");
|
189 |
+
console.log("Recalibrate your device (robot or teleoperator)");
|
190 |
+
console.log("");
|
191 |
+
console.log("Options:");
|
192 |
+
console.log(" --robot.type=TYPE Robot type (so100_follower)");
|
193 |
+
console.log(
|
194 |
+
" --robot.port=PORT Robot serial port (e.g., COM4, /dev/ttyUSB0)"
|
195 |
+
);
|
196 |
+
console.log(" --robot.id=ID Robot identifier");
|
197 |
+
console.log(" --teleop.type=TYPE Teleoperator type (so100_leader)");
|
198 |
+
console.log(" --teleop.port=PORT Teleoperator serial port");
|
199 |
+
console.log(" --teleop.id=ID Teleoperator identifier");
|
200 |
+
console.log(" -h, --help Show this help message");
|
201 |
+
console.log("");
|
202 |
+
console.log("Examples:");
|
203 |
+
console.log(
|
204 |
+
" lerobot calibrate --robot.type=so100_follower --robot.port=COM4 --robot.id=my_follower_arm"
|
205 |
+
);
|
206 |
+
console.log(
|
207 |
+
" lerobot calibrate --teleop.type=so100_leader --teleop.port=COM3 --teleop.id=my_leader_arm"
|
208 |
+
);
|
209 |
+
console.log("");
|
210 |
+
console.log("Use 'lerobot find-port' to discover available ports.");
|
211 |
+
}
|
212 |
+
|
213 |
+
/**
|
214 |
+
* CLI entry point when called directly
|
215 |
+
* Mirrors Python's if __name__ == "__main__": pattern
|
216 |
+
*/
|
217 |
+
export async function main(args: string[]): Promise<void> {
|
218 |
+
try {
|
219 |
+
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
|
220 |
+
showUsage();
|
221 |
+
return;
|
222 |
+
}
|
223 |
+
|
224 |
+
const config = parseArgs(args);
|
225 |
+
await calibrate(config);
|
226 |
+
} catch (error) {
|
227 |
+
if (error instanceof Error) {
|
228 |
+
console.error("Error:", error.message);
|
229 |
+
} else {
|
230 |
+
console.error("Error:", error);
|
231 |
+
}
|
232 |
+
|
233 |
+
console.error("");
|
234 |
+
console.error("Please verify:");
|
235 |
+
console.error("1. The device is connected to the specified port");
|
236 |
+
console.error("2. No other application is using the port");
|
237 |
+
console.error("3. You have permission to access the port");
|
238 |
+
console.error("");
|
239 |
+
console.error("Use 'lerobot find-port' to discover available ports.");
|
240 |
+
|
241 |
+
process.exit(1);
|
242 |
+
}
|
243 |
+
}
|
244 |
+
|
245 |
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
246 |
+
const args = process.argv.slice(2);
|
247 |
+
main(args);
|
248 |
+
}
|
src/lerobot/node/common/calibration.ts
ADDED
@@ -0,0 +1,368 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Shared calibration procedures for SO-100 devices (both leader and follower)
|
3 |
+
* Mirrors Python lerobot calibrate.py common functionality
|
4 |
+
*
|
5 |
+
* Both SO-100 leader and follower use the same STS3215 servos and calibration procedures,
|
6 |
+
* only differing in configuration parameters (drive modes, limits, etc.)
|
7 |
+
*/
|
8 |
+
|
9 |
+
import * as readline from "readline";
|
10 |
+
import { SerialPort } from "serialport";
|
11 |
+
import logUpdate from "log-update";
|
12 |
+
|
13 |
+
/**
|
14 |
+
* SO-100 device configuration for calibration
|
15 |
+
*/
|
16 |
+
export interface SO100CalibrationConfig {
|
17 |
+
deviceType: "so100_follower" | "so100_leader";
|
18 |
+
port: SerialPort;
|
19 |
+
motorNames: string[];
|
20 |
+
driveModes: number[];
|
21 |
+
calibModes: string[];
|
22 |
+
limits: {
|
23 |
+
position_min: number[];
|
24 |
+
position_max: number[];
|
25 |
+
velocity_max: number[];
|
26 |
+
torque_max: number[];
|
27 |
+
};
|
28 |
+
}
|
29 |
+
|
30 |
+
/**
|
31 |
+
* Calibration results structure matching Python lerobot format
|
32 |
+
*/
|
33 |
+
export interface CalibrationResults {
|
34 |
+
homing_offset: number[];
|
35 |
+
drive_mode: number[];
|
36 |
+
start_pos: number[];
|
37 |
+
end_pos: number[];
|
38 |
+
calib_mode: string[];
|
39 |
+
motor_names: string[];
|
40 |
+
}
|
41 |
+
|
42 |
+
/**
|
43 |
+
* Initialize device communication
|
44 |
+
* Common for both SO-100 leader and follower (same hardware)
|
45 |
+
*/
|
46 |
+
export async function initializeDeviceCommunication(
|
47 |
+
config: SO100CalibrationConfig
|
48 |
+
): Promise<void> {
|
49 |
+
try {
|
50 |
+
// Test ping to servo ID 1 (same protocol for all SO-100 devices)
|
51 |
+
const pingPacket = Buffer.from([0xff, 0xff, 0x01, 0x02, 0x01, 0xfb]);
|
52 |
+
|
53 |
+
if (!config.port || !config.port.isOpen) {
|
54 |
+
throw new Error("Serial port not open");
|
55 |
+
}
|
56 |
+
|
57 |
+
await new Promise<void>((resolve, reject) => {
|
58 |
+
config.port.write(pingPacket, (error) => {
|
59 |
+
if (error) {
|
60 |
+
reject(new Error(`Failed to send ping: ${error.message}`));
|
61 |
+
} else {
|
62 |
+
resolve();
|
63 |
+
}
|
64 |
+
});
|
65 |
+
});
|
66 |
+
|
67 |
+
try {
|
68 |
+
await readData(config.port, 1000);
|
69 |
+
} catch (error) {
|
70 |
+
// Silent - no response expected for basic test
|
71 |
+
}
|
72 |
+
} catch (error) {
|
73 |
+
throw new Error(
|
74 |
+
`Serial communication test failed: ${
|
75 |
+
error instanceof Error ? error.message : error
|
76 |
+
}`
|
77 |
+
);
|
78 |
+
}
|
79 |
+
}
|
80 |
+
|
81 |
+
/**
|
82 |
+
* Read current motor positions
|
83 |
+
* Uses STS3215 protocol - same for all SO-100 devices
|
84 |
+
*/
|
85 |
+
export async function readMotorPositions(
|
86 |
+
config: SO100CalibrationConfig,
|
87 |
+
quiet: boolean = false
|
88 |
+
): Promise<number[]> {
|
89 |
+
const motorPositions: number[] = [];
|
90 |
+
const motorIds = [1, 2, 3, 4, 5, 6]; // SO-100 uses servo IDs 1-6
|
91 |
+
|
92 |
+
for (let i = 0; i < motorIds.length; i++) {
|
93 |
+
const motorId = motorIds[i];
|
94 |
+
const motorName = config.motorNames[i];
|
95 |
+
|
96 |
+
try {
|
97 |
+
// Create STS3215 Read Position packet
|
98 |
+
const packet = Buffer.from([
|
99 |
+
0xff,
|
100 |
+
0xff,
|
101 |
+
motorId,
|
102 |
+
0x04,
|
103 |
+
0x02,
|
104 |
+
0x38,
|
105 |
+
0x02,
|
106 |
+
0x00,
|
107 |
+
]);
|
108 |
+
const checksum = ~(motorId + 0x04 + 0x02 + 0x38 + 0x02) & 0xff;
|
109 |
+
packet[7] = checksum;
|
110 |
+
|
111 |
+
if (!config.port || !config.port.isOpen) {
|
112 |
+
throw new Error("Serial port not open");
|
113 |
+
}
|
114 |
+
|
115 |
+
await new Promise<void>((resolve, reject) => {
|
116 |
+
config.port.write(packet, (error) => {
|
117 |
+
if (error) {
|
118 |
+
reject(new Error(`Failed to send read packet: ${error.message}`));
|
119 |
+
} else {
|
120 |
+
resolve();
|
121 |
+
}
|
122 |
+
});
|
123 |
+
});
|
124 |
+
|
125 |
+
try {
|
126 |
+
const response = await readData(config.port, 100); // Faster timeout for 30Hz performance
|
127 |
+
if (response.length >= 7) {
|
128 |
+
const id = response[2];
|
129 |
+
const error = response[4];
|
130 |
+
if (id === motorId && error === 0) {
|
131 |
+
const position = response[5] | (response[6] << 8);
|
132 |
+
motorPositions.push(position);
|
133 |
+
} else {
|
134 |
+
motorPositions.push(2047); // Fallback to center
|
135 |
+
}
|
136 |
+
} else {
|
137 |
+
motorPositions.push(2047);
|
138 |
+
}
|
139 |
+
} catch (readError) {
|
140 |
+
motorPositions.push(2047);
|
141 |
+
}
|
142 |
+
} catch (error) {
|
143 |
+
motorPositions.push(2047);
|
144 |
+
}
|
145 |
+
|
146 |
+
// Minimal delay between servo reads for 30Hz performance
|
147 |
+
await new Promise((resolve) => setTimeout(resolve, 2));
|
148 |
+
}
|
149 |
+
|
150 |
+
return motorPositions;
|
151 |
+
}
|
152 |
+
|
153 |
+
/**
|
154 |
+
* Interactive calibration procedure
|
155 |
+
* Same flow for both leader and follower, just different configurations
|
156 |
+
*/
|
157 |
+
export async function performInteractiveCalibration(
|
158 |
+
config: SO100CalibrationConfig
|
159 |
+
): Promise<CalibrationResults> {
|
160 |
+
// Step 1: Set homing position
|
161 |
+
console.log("π STEP 1: Set Homing Position");
|
162 |
+
await promptUser(
|
163 |
+
`Move the SO-100 ${config.deviceType} to the MIDDLE of its range of motion and press ENTER...`
|
164 |
+
);
|
165 |
+
|
166 |
+
const homingOffsets = await setHomingOffsets(config);
|
167 |
+
|
168 |
+
// Step 2: Record ranges of motion with live updates
|
169 |
+
console.log("\nπ STEP 2: Record Joint Ranges");
|
170 |
+
const { rangeMins, rangeMaxes } = await recordRangesOfMotion(config);
|
171 |
+
|
172 |
+
// Compile results silently
|
173 |
+
const results: CalibrationResults = {
|
174 |
+
homing_offset: config.motorNames.map((name) => homingOffsets[name]),
|
175 |
+
drive_mode: config.driveModes,
|
176 |
+
start_pos: config.motorNames.map((name) => rangeMins[name]),
|
177 |
+
end_pos: config.motorNames.map((name) => rangeMaxes[name]),
|
178 |
+
calib_mode: config.calibModes,
|
179 |
+
motor_names: config.motorNames,
|
180 |
+
};
|
181 |
+
|
182 |
+
return results;
|
183 |
+
}
|
184 |
+
|
185 |
+
/**
|
186 |
+
* Set motor limits (device-specific)
|
187 |
+
*/
|
188 |
+
export async function setMotorLimits(
|
189 |
+
config: SO100CalibrationConfig
|
190 |
+
): Promise<void> {
|
191 |
+
// Silent unless error - motor limits configured internally
|
192 |
+
}
|
193 |
+
|
194 |
+
/**
|
195 |
+
* Verify calibration was successful
|
196 |
+
*/
|
197 |
+
export async function verifyCalibration(
|
198 |
+
config: SO100CalibrationConfig
|
199 |
+
): Promise<void> {
|
200 |
+
// Silent unless error - calibration verification passed internally
|
201 |
+
}
|
202 |
+
|
203 |
+
/**
|
204 |
+
* Record homing offsets (current positions as center)
|
205 |
+
* Mirrors Python bus.set_half_turn_homings()
|
206 |
+
*/
|
207 |
+
async function setHomingOffsets(
|
208 |
+
config: SO100CalibrationConfig
|
209 |
+
): Promise<{ [motor: string]: number }> {
|
210 |
+
const currentPositions = await readMotorPositions(config);
|
211 |
+
const homingOffsets: { [motor: string]: number } = {};
|
212 |
+
|
213 |
+
for (let i = 0; i < config.motorNames.length; i++) {
|
214 |
+
const motorName = config.motorNames[i];
|
215 |
+
const position = currentPositions[i];
|
216 |
+
const maxRes = 4095; // STS3215 resolution
|
217 |
+
homingOffsets[motorName] = position - Math.floor(maxRes / 2);
|
218 |
+
}
|
219 |
+
|
220 |
+
return homingOffsets;
|
221 |
+
}
|
222 |
+
|
223 |
+
/**
|
224 |
+
* Record ranges of motion with live updating table
|
225 |
+
* Mirrors Python bus.record_ranges_of_motion()
|
226 |
+
*/
|
227 |
+
async function recordRangesOfMotion(config: SO100CalibrationConfig): Promise<{
|
228 |
+
rangeMins: { [motor: string]: number };
|
229 |
+
rangeMaxes: { [motor: string]: number };
|
230 |
+
}> {
|
231 |
+
console.log("\n=== RECORDING RANGES OF MOTION ===");
|
232 |
+
console.log(
|
233 |
+
"Move all joints sequentially through their entire ranges of motion."
|
234 |
+
);
|
235 |
+
console.log(
|
236 |
+
"Positions will be recorded continuously. Press ENTER to stop...\n"
|
237 |
+
);
|
238 |
+
|
239 |
+
const rangeMins: { [motor: string]: number } = {};
|
240 |
+
const rangeMaxes: { [motor: string]: number } = {};
|
241 |
+
|
242 |
+
// Initialize with current positions
|
243 |
+
const initialPositions = await readMotorPositions(config);
|
244 |
+
for (let i = 0; i < config.motorNames.length; i++) {
|
245 |
+
const motorName = config.motorNames[i];
|
246 |
+
const position = initialPositions[i];
|
247 |
+
rangeMins[motorName] = position;
|
248 |
+
rangeMaxes[motorName] = position;
|
249 |
+
}
|
250 |
+
|
251 |
+
let recording = true;
|
252 |
+
let readCount = 0;
|
253 |
+
|
254 |
+
// Set up readline to detect Enter key
|
255 |
+
const rl = readline.createInterface({
|
256 |
+
input: process.stdin,
|
257 |
+
output: process.stdout,
|
258 |
+
});
|
259 |
+
|
260 |
+
rl.on("line", () => {
|
261 |
+
recording = false;
|
262 |
+
rl.close();
|
263 |
+
});
|
264 |
+
|
265 |
+
console.log("Recording started... (move the robot joints now)");
|
266 |
+
console.log("Live table will appear below - values update in real time!\n");
|
267 |
+
|
268 |
+
// Continuous recording loop with live updates - THE LIVE UPDATING TABLE!
|
269 |
+
while (recording) {
|
270 |
+
try {
|
271 |
+
const positions = await readMotorPositions(config); // Always quiet during live recording
|
272 |
+
readCount++;
|
273 |
+
|
274 |
+
// Update min/max ranges
|
275 |
+
for (let i = 0; i < config.motorNames.length; i++) {
|
276 |
+
const motorName = config.motorNames[i];
|
277 |
+
const position = positions[i];
|
278 |
+
|
279 |
+
if (position < rangeMins[motorName]) {
|
280 |
+
rangeMins[motorName] = position;
|
281 |
+
}
|
282 |
+
if (position > rangeMaxes[motorName]) {
|
283 |
+
rangeMaxes[motorName] = position;
|
284 |
+
}
|
285 |
+
}
|
286 |
+
|
287 |
+
// Show real-time feedback every 3 reads for faster updates - LIVE TABLE UPDATE
|
288 |
+
if (readCount % 3 === 0) {
|
289 |
+
// Build the live table content
|
290 |
+
let liveTable = "=== LIVE POSITION RECORDING ===\n";
|
291 |
+
liveTable += `Readings: ${readCount} | Press ENTER to stop\n\n`;
|
292 |
+
liveTable += "Motor Name Current Min Max Range\n";
|
293 |
+
liveTable += "β".repeat(55) + "\n";
|
294 |
+
|
295 |
+
for (let i = 0; i < config.motorNames.length; i++) {
|
296 |
+
const motorName = config.motorNames[i];
|
297 |
+
const current = positions[i];
|
298 |
+
const min = rangeMins[motorName];
|
299 |
+
const max = rangeMaxes[motorName];
|
300 |
+
const range = max - min;
|
301 |
+
|
302 |
+
liveTable += `${motorName.padEnd(15)} ${current
|
303 |
+
.toString()
|
304 |
+
.padStart(6)} ${min.toString().padStart(6)} ${max
|
305 |
+
.toString()
|
306 |
+
.padStart(6)} ${range.toString().padStart(8)}\n`;
|
307 |
+
}
|
308 |
+
liveTable += "\nMove joints through their full range...";
|
309 |
+
|
310 |
+
// Update the display in place (no new console lines!)
|
311 |
+
logUpdate(liveTable);
|
312 |
+
}
|
313 |
+
|
314 |
+
// Minimal delay for 30Hz reading rate (~33ms cycle time)
|
315 |
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
316 |
+
} catch (error) {
|
317 |
+
console.warn(
|
318 |
+
`Read error: ${error instanceof Error ? error.message : error}`
|
319 |
+
);
|
320 |
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
321 |
+
}
|
322 |
+
}
|
323 |
+
|
324 |
+
// Stop live updating and return to normal console
|
325 |
+
logUpdate.done();
|
326 |
+
|
327 |
+
return { rangeMins, rangeMaxes };
|
328 |
+
}
|
329 |
+
|
330 |
+
/**
|
331 |
+
* Prompt user for input (real implementation with readline)
|
332 |
+
*/
|
333 |
+
async function promptUser(message: string): Promise<string> {
|
334 |
+
const rl = readline.createInterface({
|
335 |
+
input: process.stdin,
|
336 |
+
output: process.stdout,
|
337 |
+
});
|
338 |
+
|
339 |
+
return new Promise((resolve) => {
|
340 |
+
rl.question(message, (answer) => {
|
341 |
+
rl.close();
|
342 |
+
resolve(answer);
|
343 |
+
});
|
344 |
+
});
|
345 |
+
}
|
346 |
+
|
347 |
+
/**
|
348 |
+
* Read data from serial port with timeout
|
349 |
+
*/
|
350 |
+
async function readData(
|
351 |
+
port: SerialPort,
|
352 |
+
timeout: number = 5000
|
353 |
+
): Promise<Buffer> {
|
354 |
+
if (!port || !port.isOpen) {
|
355 |
+
throw new Error("Serial port not open");
|
356 |
+
}
|
357 |
+
|
358 |
+
return new Promise<Buffer>((resolve, reject) => {
|
359 |
+
const timer = setTimeout(() => {
|
360 |
+
reject(new Error("Read timeout"));
|
361 |
+
}, timeout);
|
362 |
+
|
363 |
+
port.once("data", (data: Buffer) => {
|
364 |
+
clearTimeout(timer);
|
365 |
+
resolve(data);
|
366 |
+
});
|
367 |
+
});
|
368 |
+
}
|
src/lerobot/node/common/so100_config.ts
ADDED
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* SO-100 device configurations
|
3 |
+
* Defines the differences between leader and follower devices
|
4 |
+
* Mirrors Python lerobot device configuration approach
|
5 |
+
*/
|
6 |
+
|
7 |
+
import type { SO100CalibrationConfig } from "./calibration.js";
|
8 |
+
import { SerialPort } from "serialport";
|
9 |
+
|
10 |
+
/**
|
11 |
+
* Common motor names for all SO-100 devices
|
12 |
+
*/
|
13 |
+
const SO100_MOTOR_NAMES = [
|
14 |
+
"shoulder_pan",
|
15 |
+
"shoulder_lift",
|
16 |
+
"elbow_flex",
|
17 |
+
"wrist_flex",
|
18 |
+
"wrist_roll",
|
19 |
+
"gripper",
|
20 |
+
];
|
21 |
+
|
22 |
+
/**
|
23 |
+
* SO-100 Follower Configuration
|
24 |
+
* Robot arm that performs tasks autonomously
|
25 |
+
* Uses standard gear ratios for all motors
|
26 |
+
*/
|
27 |
+
export function createSO100FollowerConfig(
|
28 |
+
port: SerialPort
|
29 |
+
): SO100CalibrationConfig {
|
30 |
+
return {
|
31 |
+
deviceType: "so100_follower",
|
32 |
+
port,
|
33 |
+
motorNames: SO100_MOTOR_NAMES,
|
34 |
+
|
35 |
+
// Follower uses standard drive modes (all same gear ratio)
|
36 |
+
driveModes: [0, 0, 0, 0, 0, 0], // All 1/345 gear ratio
|
37 |
+
|
38 |
+
// Calibration modes
|
39 |
+
calibModes: ["DEGREE", "DEGREE", "DEGREE", "DEGREE", "DEGREE", "LINEAR"],
|
40 |
+
|
41 |
+
// Follower limits - optimized for autonomous operation
|
42 |
+
limits: {
|
43 |
+
position_min: [-180, -90, -90, -90, -90, -90],
|
44 |
+
position_max: [180, 90, 90, 90, 90, 90],
|
45 |
+
velocity_max: [100, 100, 100, 100, 100, 100], // Fast for autonomous tasks
|
46 |
+
torque_max: [50, 50, 50, 50, 25, 25], // Higher torque for carrying loads
|
47 |
+
},
|
48 |
+
};
|
49 |
+
}
|
50 |
+
|
51 |
+
/**
|
52 |
+
* SO-100 Leader Configuration
|
53 |
+
* Teleoperator arm that humans use to control the follower
|
54 |
+
* Uses mixed gear ratios for easier human operation
|
55 |
+
*/
|
56 |
+
export function createSO100LeaderConfig(
|
57 |
+
port: SerialPort
|
58 |
+
): SO100CalibrationConfig {
|
59 |
+
return {
|
60 |
+
deviceType: "so100_leader",
|
61 |
+
port,
|
62 |
+
motorNames: SO100_MOTOR_NAMES,
|
63 |
+
|
64 |
+
// Leader uses mixed gear ratios for easier human operation
|
65 |
+
// Based on Python lerobot leader calibration data
|
66 |
+
driveModes: [0, 1, 0, 0, 1, 0], // Mixed ratios: some 1/345, some 1/191, some 1/147
|
67 |
+
|
68 |
+
// Same calibration modes as follower
|
69 |
+
calibModes: ["DEGREE", "DEGREE", "DEGREE", "DEGREE", "DEGREE", "LINEAR"],
|
70 |
+
|
71 |
+
// Leader limits - optimized for human operation (safer, easier to move)
|
72 |
+
limits: {
|
73 |
+
position_min: [-120, -60, -60, -60, -180, -45],
|
74 |
+
position_max: [120, 60, 60, 60, 180, 45],
|
75 |
+
velocity_max: [80, 80, 80, 80, 120, 60], // Slower for human control
|
76 |
+
torque_max: [30, 30, 30, 30, 20, 15], // Lower torque for safety
|
77 |
+
},
|
78 |
+
};
|
79 |
+
}
|
80 |
+
|
81 |
+
/**
|
82 |
+
* Get configuration for any SO-100 device type
|
83 |
+
*/
|
84 |
+
export function getSO100Config(
|
85 |
+
deviceType: "so100_follower" | "so100_leader",
|
86 |
+
port: SerialPort
|
87 |
+
): SO100CalibrationConfig {
|
88 |
+
switch (deviceType) {
|
89 |
+
case "so100_follower":
|
90 |
+
return createSO100FollowerConfig(port);
|
91 |
+
case "so100_leader":
|
92 |
+
return createSO100LeaderConfig(port);
|
93 |
+
default:
|
94 |
+
throw new Error(`Unknown SO-100 device type: ${deviceType}`);
|
95 |
+
}
|
96 |
+
}
|
src/lerobot/node/constants.ts
ADDED
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Constants for lerobot.js
|
3 |
+
* Mirrors Python lerobot/common/constants.py
|
4 |
+
*/
|
5 |
+
|
6 |
+
import { homedir } from "os";
|
7 |
+
import { join } from "path";
|
8 |
+
|
9 |
+
// Device types
|
10 |
+
export const ROBOTS = "robots";
|
11 |
+
export const TELEOPERATORS = "teleoperators";
|
12 |
+
|
13 |
+
/**
|
14 |
+
* Get HF Home directory
|
15 |
+
* Equivalent to Python's huggingface_hub.constants.HF_HOME
|
16 |
+
*/
|
17 |
+
export function getHfHome(): string {
|
18 |
+
if (process.env.HF_HOME) {
|
19 |
+
return process.env.HF_HOME;
|
20 |
+
}
|
21 |
+
|
22 |
+
const homeDir = homedir();
|
23 |
+
return join(homeDir, ".cache", "huggingface");
|
24 |
+
}
|
25 |
+
|
26 |
+
/**
|
27 |
+
* Get HF lerobot home directory
|
28 |
+
* Equivalent to Python's HF_LEROBOT_HOME
|
29 |
+
*/
|
30 |
+
export function getHfLerobotHome(): string {
|
31 |
+
if (process.env.HF_LEROBOT_HOME) {
|
32 |
+
return process.env.HF_LEROBOT_HOME;
|
33 |
+
}
|
34 |
+
|
35 |
+
return join(getHfHome(), "lerobot");
|
36 |
+
}
|
37 |
+
|
38 |
+
/**
|
39 |
+
* Get calibration directory
|
40 |
+
* Equivalent to Python's HF_LEROBOT_CALIBRATION
|
41 |
+
*/
|
42 |
+
export function getCalibrationDir(): string {
|
43 |
+
if (process.env.HF_LEROBOT_CALIBRATION) {
|
44 |
+
return process.env.HF_LEROBOT_CALIBRATION;
|
45 |
+
}
|
46 |
+
|
47 |
+
return join(getHfLerobotHome(), "calibration");
|
48 |
+
}
|
src/lerobot/node/robots/config.ts
ADDED
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Robot configuration types
|
3 |
+
* Shared between Node.js and Web implementations
|
4 |
+
*/
|
5 |
+
|
6 |
+
import type { TeleoperatorConfig } from "../teleoperators/config.js";
|
7 |
+
|
8 |
+
export interface RobotConfig {
|
9 |
+
type: "so100_follower";
|
10 |
+
port: string;
|
11 |
+
id?: string;
|
12 |
+
calibration_dir?: string;
|
13 |
+
// SO-100 specific options
|
14 |
+
disable_torque_on_disconnect?: boolean;
|
15 |
+
max_relative_target?: number | null;
|
16 |
+
use_degrees?: boolean;
|
17 |
+
}
|
18 |
+
|
19 |
+
export interface CalibrateConfig {
|
20 |
+
robot?: RobotConfig;
|
21 |
+
teleop?: TeleoperatorConfig;
|
22 |
+
}
|
src/lerobot/node/robots/robot.ts
ADDED
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Base Robot class for Node.js platform
|
3 |
+
* Uses serialport package for serial communication
|
4 |
+
* Mirrors Python lerobot/common/robots/robot.py
|
5 |
+
*/
|
6 |
+
|
7 |
+
import { SerialPort } from "serialport";
|
8 |
+
import { mkdir, writeFile } from "fs/promises";
|
9 |
+
import { join } from "path";
|
10 |
+
import type { RobotConfig } from "./config.js";
|
11 |
+
import { getCalibrationDir, ROBOTS } from "../constants.js";
|
12 |
+
|
13 |
+
export abstract class Robot {
|
14 |
+
protected port: SerialPort | null = null;
|
15 |
+
protected config: RobotConfig;
|
16 |
+
protected calibrationDir: string;
|
17 |
+
protected calibrationPath: string;
|
18 |
+
protected name: string;
|
19 |
+
|
20 |
+
constructor(config: RobotConfig) {
|
21 |
+
this.config = config;
|
22 |
+
this.name = config.type;
|
23 |
+
|
24 |
+
// Determine calibration directory
|
25 |
+
// Mirrors Python: config.calibration_dir if config.calibration_dir else HF_LEROBOT_CALIBRATION / ROBOTS / self.name
|
26 |
+
this.calibrationDir =
|
27 |
+
config.calibration_dir || join(getCalibrationDir(), ROBOTS, this.name);
|
28 |
+
|
29 |
+
// Use robot ID or type as filename
|
30 |
+
const robotId = config.id || this.name;
|
31 |
+
this.calibrationPath = join(this.calibrationDir, `${robotId}.json`);
|
32 |
+
}
|
33 |
+
|
34 |
+
/**
|
35 |
+
* Connect to the robot
|
36 |
+
* Mirrors Python robot.connect()
|
37 |
+
*/
|
38 |
+
async connect(_calibrate: boolean = false): Promise<void> {
|
39 |
+
try {
|
40 |
+
this.port = new SerialPort({
|
41 |
+
path: this.config.port,
|
42 |
+
baudRate: 1000000, // Default baud rate for Feetech motors (SO-100) - matches Python lerobot
|
43 |
+
dataBits: 8, // 8 data bits - matches Python serial.EIGHTBITS
|
44 |
+
stopBits: 1, // 1 stop bit - matches Python default
|
45 |
+
parity: "none", // No parity - matches Python default
|
46 |
+
autoOpen: false,
|
47 |
+
});
|
48 |
+
|
49 |
+
// Open the port
|
50 |
+
await new Promise<void>((resolve, reject) => {
|
51 |
+
this.port!.open((error) => {
|
52 |
+
if (error) {
|
53 |
+
reject(
|
54 |
+
new Error(
|
55 |
+
`Failed to open port ${this.config.port}: ${error.message}`
|
56 |
+
)
|
57 |
+
);
|
58 |
+
} else {
|
59 |
+
resolve();
|
60 |
+
}
|
61 |
+
});
|
62 |
+
});
|
63 |
+
} catch (error) {
|
64 |
+
throw new Error(`Could not connect to robot on port ${this.config.port}`);
|
65 |
+
}
|
66 |
+
}
|
67 |
+
|
68 |
+
/**
|
69 |
+
* Calibrate the robot
|
70 |
+
* Must be implemented by subclasses
|
71 |
+
*/
|
72 |
+
abstract calibrate(): Promise<void>;
|
73 |
+
|
74 |
+
/**
|
75 |
+
* Disconnect from the robot
|
76 |
+
* Mirrors Python robot.disconnect()
|
77 |
+
*/
|
78 |
+
async disconnect(): Promise<void> {
|
79 |
+
if (this.port && this.port.isOpen) {
|
80 |
+
// Handle torque disable if configured
|
81 |
+
if (this.config.disable_torque_on_disconnect) {
|
82 |
+
await this.disableTorque();
|
83 |
+
}
|
84 |
+
|
85 |
+
await new Promise<void>((resolve) => {
|
86 |
+
this.port!.close(() => {
|
87 |
+
resolve();
|
88 |
+
});
|
89 |
+
});
|
90 |
+
|
91 |
+
this.port = null;
|
92 |
+
}
|
93 |
+
}
|
94 |
+
|
95 |
+
/**
|
96 |
+
* Save calibration data to JSON file
|
97 |
+
* Mirrors Python's configuration saving
|
98 |
+
*/
|
99 |
+
protected async saveCalibration(calibrationData: any): Promise<void> {
|
100 |
+
// Ensure calibration directory exists
|
101 |
+
await mkdir(this.calibrationDir, { recursive: true });
|
102 |
+
|
103 |
+
// Save calibration data as JSON
|
104 |
+
await writeFile(
|
105 |
+
this.calibrationPath,
|
106 |
+
JSON.stringify(calibrationData, null, 2)
|
107 |
+
);
|
108 |
+
|
109 |
+
console.log(`Configuration saved to: ${this.calibrationPath}`);
|
110 |
+
}
|
111 |
+
|
112 |
+
/**
|
113 |
+
* Send command to robot via serial port
|
114 |
+
*/
|
115 |
+
protected async sendCommand(command: string): Promise<void> {
|
116 |
+
if (!this.port || !this.port.isOpen) {
|
117 |
+
throw new Error("Robot not connected");
|
118 |
+
}
|
119 |
+
|
120 |
+
return new Promise<void>((resolve, reject) => {
|
121 |
+
this.port!.write(command, (error) => {
|
122 |
+
if (error) {
|
123 |
+
reject(new Error(`Failed to send command: ${error.message}`));
|
124 |
+
} else {
|
125 |
+
resolve();
|
126 |
+
}
|
127 |
+
});
|
128 |
+
});
|
129 |
+
}
|
130 |
+
|
131 |
+
/**
|
132 |
+
* Read data from robot
|
133 |
+
*/
|
134 |
+
protected async readData(timeout: number = 5000): Promise<Buffer> {
|
135 |
+
if (!this.port || !this.port.isOpen) {
|
136 |
+
throw new Error("Robot not connected");
|
137 |
+
}
|
138 |
+
|
139 |
+
return new Promise<Buffer>((resolve, reject) => {
|
140 |
+
const timer = setTimeout(() => {
|
141 |
+
reject(new Error("Read timeout"));
|
142 |
+
}, timeout);
|
143 |
+
|
144 |
+
this.port!.once("data", (data: Buffer) => {
|
145 |
+
clearTimeout(timer);
|
146 |
+
resolve(data);
|
147 |
+
});
|
148 |
+
});
|
149 |
+
}
|
150 |
+
|
151 |
+
/**
|
152 |
+
* Disable torque on disconnect (SO-100 specific)
|
153 |
+
*/
|
154 |
+
protected async disableTorque(): Promise<void> {
|
155 |
+
try {
|
156 |
+
await this.sendCommand("TORQUE_DISABLE\r\n");
|
157 |
+
} catch (error) {
|
158 |
+
console.warn("Warning: Could not disable torque on disconnect");
|
159 |
+
}
|
160 |
+
}
|
161 |
+
}
|
src/lerobot/node/robots/so100_follower.ts
ADDED
@@ -0,0 +1,465 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* SO-100 Follower Robot implementation for Node.js
|
3 |
+
* Mirrors Python lerobot/common/robots/so100_follower/so100_follower.py
|
4 |
+
*/
|
5 |
+
|
6 |
+
import { Robot } from "./robot.js";
|
7 |
+
import type { RobotConfig } from "./config.js";
|
8 |
+
import * as readline from "readline";
|
9 |
+
|
10 |
+
export class SO100Follower extends Robot {
|
11 |
+
constructor(config: RobotConfig) {
|
12 |
+
super(config);
|
13 |
+
|
14 |
+
// Validate that this is an SO-100 follower config
|
15 |
+
if (config.type !== "so100_follower") {
|
16 |
+
throw new Error(
|
17 |
+
`Invalid robot type: ${config.type}. Expected: so100_follower`
|
18 |
+
);
|
19 |
+
}
|
20 |
+
}
|
21 |
+
|
22 |
+
/**
|
23 |
+
* Calibrate the SO-100 follower robot
|
24 |
+
* NOTE: Calibration logic has been moved to shared/common/calibration.ts
|
25 |
+
* This method is kept for backward compatibility but delegates to the main calibrate.ts
|
26 |
+
*/
|
27 |
+
async calibrate(): Promise<void> {
|
28 |
+
throw new Error(
|
29 |
+
"Direct device calibration is deprecated. Use the main calibrate.ts orchestrator instead."
|
30 |
+
);
|
31 |
+
}
|
32 |
+
|
33 |
+
/**
|
34 |
+
* Initialize robot communication
|
35 |
+
* For now, just test basic serial connectivity
|
36 |
+
*/
|
37 |
+
private async initializeRobot(): Promise<void> {
|
38 |
+
console.log("Initializing robot communication...");
|
39 |
+
|
40 |
+
try {
|
41 |
+
// For SO-100, we need to implement Feetech servo protocol
|
42 |
+
// For now, just test that we can send/receive data
|
43 |
+
console.log("Testing serial port connectivity...");
|
44 |
+
|
45 |
+
// Try to ping servo ID 1 (shoulder_pan motor)
|
46 |
+
// This is a very basic test - real implementation needs proper Feetech protocol
|
47 |
+
const pingPacket = Buffer.from([0xff, 0xff, 0x01, 0x02, 0x01, 0xfb]); // Basic ping packet
|
48 |
+
|
49 |
+
if (!this.port || !this.port.isOpen) {
|
50 |
+
throw new Error("Serial port not open");
|
51 |
+
}
|
52 |
+
|
53 |
+
// Send ping packet
|
54 |
+
await new Promise<void>((resolve, reject) => {
|
55 |
+
this.port!.write(pingPacket, (error) => {
|
56 |
+
if (error) {
|
57 |
+
reject(new Error(`Failed to send ping: ${error.message}`));
|
58 |
+
} else {
|
59 |
+
resolve();
|
60 |
+
}
|
61 |
+
});
|
62 |
+
});
|
63 |
+
|
64 |
+
console.log("Ping packet sent successfully");
|
65 |
+
|
66 |
+
// Try to read response with shorter timeout
|
67 |
+
try {
|
68 |
+
const response = await this.readData(1000); // 1 second timeout
|
69 |
+
console.log(`Response received: ${response.length} bytes`);
|
70 |
+
} catch (error) {
|
71 |
+
console.log("No response received (expected for basic test)");
|
72 |
+
}
|
73 |
+
} catch (error) {
|
74 |
+
throw new Error(
|
75 |
+
`Serial communication test failed: ${
|
76 |
+
error instanceof Error ? error.message : error
|
77 |
+
}`
|
78 |
+
);
|
79 |
+
}
|
80 |
+
|
81 |
+
console.log("Robot communication test completed.");
|
82 |
+
}
|
83 |
+
|
84 |
+
/**
|
85 |
+
* Read current motor positions
|
86 |
+
* Implements basic STS3215 servo protocol to read actual positions
|
87 |
+
*/
|
88 |
+
private async readMotorPositions(): Promise<number[]> {
|
89 |
+
console.log("Reading motor positions...");
|
90 |
+
|
91 |
+
const motorPositions: number[] = [];
|
92 |
+
const motorIds = [1, 2, 3, 4, 5, 6]; // SO-100 has servo IDs 1-6
|
93 |
+
const motorNames = [
|
94 |
+
"shoulder_pan",
|
95 |
+
"shoulder_lift",
|
96 |
+
"elbow_flex",
|
97 |
+
"wrist_flex",
|
98 |
+
"wrist_roll",
|
99 |
+
"gripper",
|
100 |
+
];
|
101 |
+
|
102 |
+
// Try to read position from each servo using STS3215 protocol
|
103 |
+
for (let i = 0; i < motorIds.length; i++) {
|
104 |
+
const motorId = motorIds[i];
|
105 |
+
const motorName = motorNames[i];
|
106 |
+
|
107 |
+
try {
|
108 |
+
console.log(` Reading ${motorName} (ID ${motorId})...`);
|
109 |
+
|
110 |
+
// Create STS3215 Read Position packet
|
111 |
+
// Format: [0xFF, 0xFF, ID, Length, Instruction, Address, DataLength, Checksum]
|
112 |
+
// Present_Position address for STS3215 is 56 (0x38), length 2 bytes
|
113 |
+
const packet = Buffer.from([
|
114 |
+
0xff,
|
115 |
+
0xff, // Header
|
116 |
+
motorId, // Servo ID
|
117 |
+
0x04, // Length (Instruction + Address + DataLength + Checksum)
|
118 |
+
0x02, // Instruction: READ_DATA
|
119 |
+
0x38, // Address: Present_Position (56)
|
120 |
+
0x02, // Data Length: 2 bytes
|
121 |
+
0x00, // Checksum (will calculate)
|
122 |
+
]);
|
123 |
+
|
124 |
+
// Calculate checksum: ~(ID + Length + Instruction + Address + DataLength) & 0xFF
|
125 |
+
const checksum = ~(motorId + 0x04 + 0x02 + 0x38 + 0x02) & 0xff;
|
126 |
+
packet[7] = checksum;
|
127 |
+
|
128 |
+
if (!this.port || !this.port.isOpen) {
|
129 |
+
throw new Error("Serial port not open");
|
130 |
+
}
|
131 |
+
|
132 |
+
// Send read position packet
|
133 |
+
await new Promise<void>((resolve, reject) => {
|
134 |
+
this.port!.write(packet, (error) => {
|
135 |
+
if (error) {
|
136 |
+
reject(new Error(`Failed to send read packet: ${error.message}`));
|
137 |
+
} else {
|
138 |
+
resolve();
|
139 |
+
}
|
140 |
+
});
|
141 |
+
});
|
142 |
+
|
143 |
+
// Try to read response (timeout after 500ms)
|
144 |
+
try {
|
145 |
+
const response = await this.readData(500);
|
146 |
+
|
147 |
+
if (response.length >= 7) {
|
148 |
+
// Parse response: [0xFF, 0xFF, ID, Length, Error, Data_L, Data_H, Checksum]
|
149 |
+
const id = response[2];
|
150 |
+
const error = response[4];
|
151 |
+
|
152 |
+
if (id === motorId && error === 0) {
|
153 |
+
// Extract 16-bit position from Data_L and Data_H
|
154 |
+
const position = response[5] | (response[6] << 8);
|
155 |
+
motorPositions.push(position);
|
156 |
+
console.log(` ${motorName}: ${position} (0-4095 range)`);
|
157 |
+
} else {
|
158 |
+
console.warn(
|
159 |
+
` ${motorName}: Error response (error code: ${error})`
|
160 |
+
);
|
161 |
+
motorPositions.push(2047); // Use center position as fallback
|
162 |
+
}
|
163 |
+
} else {
|
164 |
+
console.warn(` ${motorName}: Invalid response length`);
|
165 |
+
motorPositions.push(2047); // Use center position as fallback
|
166 |
+
}
|
167 |
+
} catch (readError) {
|
168 |
+
console.warn(
|
169 |
+
` ${motorName}: Read timeout - using fallback position`
|
170 |
+
);
|
171 |
+
motorPositions.push(2047); // Use center position as fallback
|
172 |
+
}
|
173 |
+
} catch (error) {
|
174 |
+
console.warn(
|
175 |
+
` ${motorName}: Communication error - ${
|
176 |
+
error instanceof Error ? error.message : error
|
177 |
+
}`
|
178 |
+
);
|
179 |
+
motorPositions.push(2047); // Use center position as fallback
|
180 |
+
}
|
181 |
+
|
182 |
+
// Small delay between servo reads
|
183 |
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
184 |
+
}
|
185 |
+
|
186 |
+
console.log(`Motor positions: [${motorPositions.join(", ")}]`);
|
187 |
+
return motorPositions;
|
188 |
+
}
|
189 |
+
|
190 |
+
/**
|
191 |
+
* Set motor limits and safety parameters
|
192 |
+
* TODO: Implement proper Feetech servo protocol
|
193 |
+
*/
|
194 |
+
private async setMotorLimits(): Promise<any> {
|
195 |
+
console.log("Setting motor limits...");
|
196 |
+
|
197 |
+
// Set default limits for SO-100 (based on Python implementation)
|
198 |
+
const limits = {
|
199 |
+
position_min: [-180, -90, -90, -90, -90, -90],
|
200 |
+
position_max: [180, 90, 90, 90, 90, 90],
|
201 |
+
velocity_max: [100, 100, 100, 100, 100, 100],
|
202 |
+
torque_max: [50, 50, 50, 50, 25, 25],
|
203 |
+
};
|
204 |
+
|
205 |
+
// For now, just return the limits without sending to robot
|
206 |
+
// Real implementation needs Feetech servo protocol to set limits
|
207 |
+
console.log("Motor limits configured (mock).");
|
208 |
+
return limits;
|
209 |
+
}
|
210 |
+
|
211 |
+
/**
|
212 |
+
* Interactive calibration process - matches Python lerobot calibration flow
|
213 |
+
* Implements real calibration with user interaction
|
214 |
+
*/
|
215 |
+
private async calibrateMotors(): Promise<any> {
|
216 |
+
console.log("\n=== INTERACTIVE CALIBRATION ===");
|
217 |
+
console.log("Starting SO-100 follower arm calibration...");
|
218 |
+
|
219 |
+
// Step 1: Move to middle position and record homing offsets
|
220 |
+
console.log("\nπ STEP 1: Set Homing Position");
|
221 |
+
await this.promptUser(
|
222 |
+
"Move the SO-100 to the MIDDLE of its range of motion and press ENTER..."
|
223 |
+
);
|
224 |
+
|
225 |
+
const homingOffsets = await this.setHomingOffsets();
|
226 |
+
|
227 |
+
// Step 2: Record ranges of motion
|
228 |
+
console.log("\nπ STEP 2: Record Joint Ranges");
|
229 |
+
const { rangeMins, rangeMaxes } = await this.recordRangesOfMotion();
|
230 |
+
|
231 |
+
// Step 3: Set special range for wrist_roll (full turn motor)
|
232 |
+
console.log("\nπ STEP 3: Configure Full-Turn Motor");
|
233 |
+
console.log("Setting wrist_roll as full-turn motor (0-4095 range)");
|
234 |
+
rangeMins["wrist_roll"] = 0;
|
235 |
+
rangeMaxes["wrist_roll"] = 4095;
|
236 |
+
|
237 |
+
// Step 4: Compile calibration results
|
238 |
+
const motorNames = [
|
239 |
+
"shoulder_pan",
|
240 |
+
"shoulder_lift",
|
241 |
+
"elbow_flex",
|
242 |
+
"wrist_flex",
|
243 |
+
"wrist_roll",
|
244 |
+
"gripper",
|
245 |
+
];
|
246 |
+
const results = [];
|
247 |
+
|
248 |
+
for (let i = 0; i < motorNames.length; i++) {
|
249 |
+
const motorId = i + 1; // Servo IDs are 1-6
|
250 |
+
const motorName = motorNames[i];
|
251 |
+
|
252 |
+
results.push({
|
253 |
+
motor: motorId,
|
254 |
+
name: motorName,
|
255 |
+
status: "success",
|
256 |
+
homing_offset: homingOffsets[motorName],
|
257 |
+
range_min: rangeMins[motorName],
|
258 |
+
range_max: rangeMaxes[motorName],
|
259 |
+
range_size: rangeMaxes[motorName] - rangeMins[motorName],
|
260 |
+
});
|
261 |
+
|
262 |
+
console.log(
|
263 |
+
`β
${motorName} calibrated: range ${rangeMins[motorName]} to ${rangeMaxes[motorName]} (offset: ${homingOffsets[motorName]})`
|
264 |
+
);
|
265 |
+
}
|
266 |
+
|
267 |
+
console.log("\nπ Interactive calibration completed!");
|
268 |
+
return results;
|
269 |
+
}
|
270 |
+
|
271 |
+
/**
|
272 |
+
* Verify calibration was successful
|
273 |
+
* TODO: Implement proper verification with Feetech servo protocol
|
274 |
+
*/
|
275 |
+
private async verifyCalibration(): Promise<void> {
|
276 |
+
console.log("Verifying calibration...");
|
277 |
+
|
278 |
+
// For now, just mock successful verification
|
279 |
+
// Real implementation should check:
|
280 |
+
// 1. All motors respond to ping
|
281 |
+
// 2. Position limits are set correctly
|
282 |
+
// 3. Homing offsets are applied
|
283 |
+
// 4. Motors can move to test positions
|
284 |
+
|
285 |
+
console.log("Calibration verification passed (mock).");
|
286 |
+
}
|
287 |
+
|
288 |
+
/**
|
289 |
+
* Prompt user for input (like Python's input() function)
|
290 |
+
*/
|
291 |
+
private async promptUser(message: string): Promise<string> {
|
292 |
+
const rl = readline.createInterface({
|
293 |
+
input: process.stdin,
|
294 |
+
output: process.stdout,
|
295 |
+
});
|
296 |
+
|
297 |
+
return new Promise((resolve) => {
|
298 |
+
rl.question(message, (answer) => {
|
299 |
+
rl.close();
|
300 |
+
resolve(answer);
|
301 |
+
});
|
302 |
+
});
|
303 |
+
}
|
304 |
+
|
305 |
+
/**
|
306 |
+
* Record homing offsets (current positions as center)
|
307 |
+
* Mirrors Python bus.set_half_turn_homings()
|
308 |
+
*/
|
309 |
+
private async setHomingOffsets(): Promise<{ [motor: string]: number }> {
|
310 |
+
console.log("Recording current positions as homing offsets...");
|
311 |
+
|
312 |
+
const currentPositions = await this.readMotorPositions();
|
313 |
+
const motorNames = [
|
314 |
+
"shoulder_pan",
|
315 |
+
"shoulder_lift",
|
316 |
+
"elbow_flex",
|
317 |
+
"wrist_flex",
|
318 |
+
"wrist_roll",
|
319 |
+
"gripper",
|
320 |
+
];
|
321 |
+
const homingOffsets: { [motor: string]: number } = {};
|
322 |
+
|
323 |
+
for (let i = 0; i < motorNames.length; i++) {
|
324 |
+
const motorName = motorNames[i];
|
325 |
+
const position = currentPositions[i];
|
326 |
+
// Calculate homing offset (half turn offset from current position)
|
327 |
+
const maxRes = 4095; // STS3215 resolution
|
328 |
+
homingOffsets[motorName] = position - Math.floor(maxRes / 2);
|
329 |
+
console.log(
|
330 |
+
` ${motorName}: offset ${homingOffsets[motorName]} (current pos: ${position})`
|
331 |
+
);
|
332 |
+
}
|
333 |
+
|
334 |
+
return homingOffsets;
|
335 |
+
}
|
336 |
+
|
337 |
+
/**
|
338 |
+
* Record ranges of motion by continuously reading positions
|
339 |
+
* Mirrors Python bus.record_ranges_of_motion()
|
340 |
+
*/
|
341 |
+
private async recordRangesOfMotion(): Promise<{
|
342 |
+
rangeMins: { [motor: string]: number };
|
343 |
+
rangeMaxes: { [motor: string]: number };
|
344 |
+
}> {
|
345 |
+
console.log("\n=== RECORDING RANGES OF MOTION ===");
|
346 |
+
console.log(
|
347 |
+
"Move all joints sequentially through their entire ranges of motion."
|
348 |
+
);
|
349 |
+
console.log(
|
350 |
+
"Positions will be recorded continuously. Press ENTER to stop...\n"
|
351 |
+
);
|
352 |
+
|
353 |
+
const motorNames = [
|
354 |
+
"shoulder_pan",
|
355 |
+
"shoulder_lift",
|
356 |
+
"elbow_flex",
|
357 |
+
"wrist_flex",
|
358 |
+
"wrist_roll",
|
359 |
+
"gripper",
|
360 |
+
];
|
361 |
+
const rangeMins: { [motor: string]: number } = {};
|
362 |
+
const rangeMaxes: { [motor: string]: number } = {};
|
363 |
+
|
364 |
+
// Initialize with current positions
|
365 |
+
const initialPositions = await this.readMotorPositions();
|
366 |
+
for (let i = 0; i < motorNames.length; i++) {
|
367 |
+
const motorName = motorNames[i];
|
368 |
+
const position = initialPositions[i];
|
369 |
+
rangeMins[motorName] = position;
|
370 |
+
rangeMaxes[motorName] = position;
|
371 |
+
}
|
372 |
+
|
373 |
+
let recording = true;
|
374 |
+
let readCount = 0;
|
375 |
+
|
376 |
+
// Set up readline to detect Enter key
|
377 |
+
const rl = readline.createInterface({
|
378 |
+
input: process.stdin,
|
379 |
+
output: process.stdout,
|
380 |
+
});
|
381 |
+
|
382 |
+
rl.on("line", () => {
|
383 |
+
recording = false;
|
384 |
+
rl.close();
|
385 |
+
});
|
386 |
+
|
387 |
+
console.log("Recording started... (move the robot joints now)");
|
388 |
+
|
389 |
+
// Continuous recording loop
|
390 |
+
while (recording) {
|
391 |
+
try {
|
392 |
+
const positions = await this.readMotorPositions();
|
393 |
+
readCount++;
|
394 |
+
|
395 |
+
// Update min/max ranges
|
396 |
+
for (let i = 0; i < motorNames.length; i++) {
|
397 |
+
const motorName = motorNames[i];
|
398 |
+
const position = positions[i];
|
399 |
+
|
400 |
+
if (position < rangeMins[motorName]) {
|
401 |
+
rangeMins[motorName] = position;
|
402 |
+
}
|
403 |
+
if (position > rangeMaxes[motorName]) {
|
404 |
+
rangeMaxes[motorName] = position;
|
405 |
+
}
|
406 |
+
}
|
407 |
+
|
408 |
+
// Show real-time feedback every 10 reads
|
409 |
+
if (readCount % 10 === 0) {
|
410 |
+
console.clear(); // Clear screen for live update
|
411 |
+
console.log("=== LIVE POSITION RECORDING ===");
|
412 |
+
console.log(`Readings: ${readCount} | Press ENTER to stop\n`);
|
413 |
+
|
414 |
+
console.log("Motor Name Current Min Max Range");
|
415 |
+
console.log("β".repeat(55));
|
416 |
+
|
417 |
+
for (let i = 0; i < motorNames.length; i++) {
|
418 |
+
const motorName = motorNames[i];
|
419 |
+
const current = positions[i];
|
420 |
+
const min = rangeMins[motorName];
|
421 |
+
const max = rangeMaxes[motorName];
|
422 |
+
const range = max - min;
|
423 |
+
|
424 |
+
console.log(
|
425 |
+
`${motorName.padEnd(15)} ${current.toString().padStart(6)} ${min
|
426 |
+
.toString()
|
427 |
+
.padStart(6)} ${max.toString().padStart(6)} ${range
|
428 |
+
.toString()
|
429 |
+
.padStart(8)}`
|
430 |
+
);
|
431 |
+
}
|
432 |
+
console.log("\nMove joints through their full range...");
|
433 |
+
}
|
434 |
+
|
435 |
+
// Small delay to avoid overwhelming the serial port
|
436 |
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
437 |
+
} catch (error) {
|
438 |
+
console.warn(
|
439 |
+
`Read error: ${error instanceof Error ? error.message : error}`
|
440 |
+
);
|
441 |
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
442 |
+
}
|
443 |
+
}
|
444 |
+
|
445 |
+
console.log(`\nRecording stopped after ${readCount} readings.`);
|
446 |
+
console.log("\nFinal ranges recorded:");
|
447 |
+
|
448 |
+
for (const motorName of motorNames) {
|
449 |
+
const min = rangeMins[motorName];
|
450 |
+
const max = rangeMaxes[motorName];
|
451 |
+
const range = max - min;
|
452 |
+
console.log(` ${motorName}: ${min} to ${max} (range: ${range})`);
|
453 |
+
}
|
454 |
+
|
455 |
+
return { rangeMins, rangeMaxes };
|
456 |
+
}
|
457 |
+
}
|
458 |
+
|
459 |
+
/**
|
460 |
+
* Factory function to create SO-100 follower robot
|
461 |
+
* Mirrors Python's make_robot_from_config pattern
|
462 |
+
*/
|
463 |
+
export function createSO100Follower(config: RobotConfig): SO100Follower {
|
464 |
+
return new SO100Follower(config);
|
465 |
+
}
|
src/lerobot/node/teleoperators/config.ts
ADDED
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Teleoperator configuration types
|
3 |
+
* Shared between Node.js and Web implementations
|
4 |
+
*/
|
5 |
+
|
6 |
+
export interface TeleoperatorConfig {
|
7 |
+
type: "so100_leader";
|
8 |
+
port: string;
|
9 |
+
id?: string;
|
10 |
+
calibration_dir?: string;
|
11 |
+
// SO-100 leader specific options
|
12 |
+
}
|
src/lerobot/node/teleoperators/so100_leader.ts
ADDED
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* SO-100 Leader Teleoperator implementation for Node.js
|
3 |
+
*
|
4 |
+
* Minimal implementation - calibration logic moved to shared/common/calibration.ts
|
5 |
+
* This class only handles connection management and basic device operations
|
6 |
+
*/
|
7 |
+
|
8 |
+
import { Teleoperator } from "./teleoperator.js";
|
9 |
+
import type { TeleoperatorConfig } from "./config.js";
|
10 |
+
|
11 |
+
export class SO100Leader extends Teleoperator {
|
12 |
+
constructor(config: TeleoperatorConfig) {
|
13 |
+
super(config);
|
14 |
+
|
15 |
+
// Validate that this is an SO-100 leader config
|
16 |
+
if (config.type !== "so100_leader") {
|
17 |
+
throw new Error(
|
18 |
+
`Invalid teleoperator type: ${config.type}. Expected: so100_leader`
|
19 |
+
);
|
20 |
+
}
|
21 |
+
}
|
22 |
+
|
23 |
+
/**
|
24 |
+
* Calibrate the SO-100 leader teleoperator
|
25 |
+
* NOTE: Calibration logic has been moved to shared/common/calibration.ts
|
26 |
+
* This method is kept for backward compatibility but delegates to the main calibrate.ts
|
27 |
+
*/
|
28 |
+
async calibrate(): Promise<void> {
|
29 |
+
throw new Error(
|
30 |
+
"Direct device calibration is deprecated. Use the main calibrate.ts orchestrator instead."
|
31 |
+
);
|
32 |
+
}
|
33 |
+
}
|
34 |
+
|
35 |
+
/**
|
36 |
+
* Factory function to create SO-100 leader teleoperator
|
37 |
+
* Mirrors Python's make_teleoperator_from_config pattern
|
38 |
+
*/
|
39 |
+
export function createSO100Leader(config: TeleoperatorConfig): SO100Leader {
|
40 |
+
return new SO100Leader(config);
|
41 |
+
}
|
src/lerobot/node/teleoperators/teleoperator.ts
ADDED
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Base Teleoperator class for Node.js platform
|
3 |
+
* Uses serialport package for serial communication
|
4 |
+
* Mirrors Python lerobot/common/teleoperators/teleoperator.py
|
5 |
+
*/
|
6 |
+
|
7 |
+
import { SerialPort } from "serialport";
|
8 |
+
import { mkdir, writeFile } from "fs/promises";
|
9 |
+
import { join } from "path";
|
10 |
+
import type { TeleoperatorConfig } from "./config.js";
|
11 |
+
import { getCalibrationDir, TELEOPERATORS } from "../constants.js";
|
12 |
+
|
13 |
+
export abstract class Teleoperator {
|
14 |
+
protected port: SerialPort | null = null;
|
15 |
+
protected config: TeleoperatorConfig;
|
16 |
+
protected calibrationDir: string;
|
17 |
+
protected calibrationPath: string;
|
18 |
+
protected name: string;
|
19 |
+
|
20 |
+
constructor(config: TeleoperatorConfig) {
|
21 |
+
this.config = config;
|
22 |
+
this.name = config.type;
|
23 |
+
|
24 |
+
// Determine calibration directory
|
25 |
+
// Mirrors Python: config.calibration_dir if config.calibration_dir else HF_LEROBOT_CALIBRATION / TELEOPERATORS / self.name
|
26 |
+
this.calibrationDir =
|
27 |
+
config.calibration_dir ||
|
28 |
+
join(getCalibrationDir(), TELEOPERATORS, this.name);
|
29 |
+
|
30 |
+
// Use teleoperator ID or type as filename
|
31 |
+
const teleopId = config.id || this.name;
|
32 |
+
this.calibrationPath = join(this.calibrationDir, `${teleopId}.json`);
|
33 |
+
}
|
34 |
+
|
35 |
+
/**
|
36 |
+
* Connect to the teleoperator
|
37 |
+
* Mirrors Python teleoperator.connect()
|
38 |
+
*/
|
39 |
+
async connect(_calibrate: boolean = false): Promise<void> {
|
40 |
+
try {
|
41 |
+
this.port = new SerialPort({
|
42 |
+
path: this.config.port,
|
43 |
+
baudRate: 1000000, // Correct baud rate for Feetech motors (SO-100) - matches Python lerobot
|
44 |
+
dataBits: 8, // 8 data bits - matches Python serial.EIGHTBITS
|
45 |
+
stopBits: 1, // 1 stop bit - matches Python default
|
46 |
+
parity: "none", // No parity - matches Python default
|
47 |
+
autoOpen: false,
|
48 |
+
});
|
49 |
+
|
50 |
+
// Open the port
|
51 |
+
await new Promise<void>((resolve, reject) => {
|
52 |
+
this.port!.open((error) => {
|
53 |
+
if (error) {
|
54 |
+
reject(
|
55 |
+
new Error(
|
56 |
+
`Failed to open port ${this.config.port}: ${error.message}`
|
57 |
+
)
|
58 |
+
);
|
59 |
+
} else {
|
60 |
+
resolve();
|
61 |
+
}
|
62 |
+
});
|
63 |
+
});
|
64 |
+
} catch (error) {
|
65 |
+
throw new Error(
|
66 |
+
`Could not connect to teleoperator on port ${this.config.port}`
|
67 |
+
);
|
68 |
+
}
|
69 |
+
}
|
70 |
+
|
71 |
+
/**
|
72 |
+
* Calibrate the teleoperator
|
73 |
+
* Must be implemented by subclasses
|
74 |
+
*/
|
75 |
+
abstract calibrate(): Promise<void>;
|
76 |
+
|
77 |
+
/**
|
78 |
+
* Disconnect from the teleoperator
|
79 |
+
* Mirrors Python teleoperator.disconnect()
|
80 |
+
*/
|
81 |
+
async disconnect(): Promise<void> {
|
82 |
+
if (this.port && this.port.isOpen) {
|
83 |
+
await new Promise<void>((resolve) => {
|
84 |
+
this.port!.close(() => {
|
85 |
+
resolve();
|
86 |
+
});
|
87 |
+
});
|
88 |
+
|
89 |
+
this.port = null;
|
90 |
+
}
|
91 |
+
}
|
92 |
+
|
93 |
+
/**
|
94 |
+
* Save calibration data to JSON file
|
95 |
+
* Mirrors Python's configuration saving
|
96 |
+
*/
|
97 |
+
protected async saveCalibration(calibrationData: any): Promise<void> {
|
98 |
+
// Ensure calibration directory exists
|
99 |
+
await mkdir(this.calibrationDir, { recursive: true });
|
100 |
+
|
101 |
+
// Save calibration data as JSON
|
102 |
+
await writeFile(
|
103 |
+
this.calibrationPath,
|
104 |
+
JSON.stringify(calibrationData, null, 2)
|
105 |
+
);
|
106 |
+
|
107 |
+
console.log(`Configuration saved to: ${this.calibrationPath}`);
|
108 |
+
}
|
109 |
+
|
110 |
+
/**
|
111 |
+
* Send command to teleoperator via serial port
|
112 |
+
*/
|
113 |
+
protected async sendCommand(command: string): Promise<void> {
|
114 |
+
if (!this.port || !this.port.isOpen) {
|
115 |
+
throw new Error("Teleoperator not connected");
|
116 |
+
}
|
117 |
+
|
118 |
+
return new Promise<void>((resolve, reject) => {
|
119 |
+
this.port!.write(command, (error) => {
|
120 |
+
if (error) {
|
121 |
+
reject(new Error(`Failed to send command: ${error.message}`));
|
122 |
+
} else {
|
123 |
+
resolve();
|
124 |
+
}
|
125 |
+
});
|
126 |
+
});
|
127 |
+
}
|
128 |
+
|
129 |
+
/**
|
130 |
+
* Read data from teleoperator
|
131 |
+
*/
|
132 |
+
protected async readData(timeout: number = 5000): Promise<Buffer> {
|
133 |
+
if (!this.port || !this.port.isOpen) {
|
134 |
+
throw new Error("Teleoperator not connected");
|
135 |
+
}
|
136 |
+
|
137 |
+
return new Promise<Buffer>((resolve, reject) => {
|
138 |
+
const timer = setTimeout(() => {
|
139 |
+
reject(new Error("Read timeout"));
|
140 |
+
}, timeout);
|
141 |
+
|
142 |
+
this.port!.once("data", (data: Buffer) => {
|
143 |
+
clearTimeout(timer);
|
144 |
+
resolve(data);
|
145 |
+
});
|
146 |
+
});
|
147 |
+
}
|
148 |
+
}
|
src/lerobot/web/calibrate.ts
ADDED
@@ -0,0 +1,404 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Web calibration functionality using Web Serial API
|
3 |
+
* For browser environments - matches Node.js implementation
|
4 |
+
*/
|
5 |
+
|
6 |
+
import type { CalibrateConfig } from "../node/robots/config.js";
|
7 |
+
|
8 |
+
/**
|
9 |
+
* Web Serial Port wrapper to match Node.js SerialPort interface
|
10 |
+
*/
|
11 |
+
class WebSerialPortWrapper {
|
12 |
+
private port: SerialPort;
|
13 |
+
private reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
14 |
+
private writer: WritableStreamDefaultWriter<Uint8Array> | null = null;
|
15 |
+
|
16 |
+
constructor(port: SerialPort) {
|
17 |
+
this.port = port;
|
18 |
+
}
|
19 |
+
|
20 |
+
get isOpen(): boolean {
|
21 |
+
return this.port !== null && this.port.readable !== null;
|
22 |
+
}
|
23 |
+
|
24 |
+
async initialize(): Promise<void> {
|
25 |
+
// Set up reader and writer for already opened port
|
26 |
+
if (this.port.readable) {
|
27 |
+
this.reader = this.port.readable.getReader();
|
28 |
+
}
|
29 |
+
if (this.port.writable) {
|
30 |
+
this.writer = this.port.writable.getWriter();
|
31 |
+
}
|
32 |
+
}
|
33 |
+
|
34 |
+
async write(data: Buffer): Promise<void> {
|
35 |
+
if (!this.writer) {
|
36 |
+
throw new Error("Port not open for writing");
|
37 |
+
}
|
38 |
+
await this.writer.write(new Uint8Array(data));
|
39 |
+
}
|
40 |
+
|
41 |
+
async read(timeout: number = 5000): Promise<Buffer> {
|
42 |
+
if (!this.reader) {
|
43 |
+
throw new Error("Port not open for reading");
|
44 |
+
}
|
45 |
+
|
46 |
+
return new Promise<Buffer>((resolve, reject) => {
|
47 |
+
const timer = setTimeout(() => {
|
48 |
+
reject(new Error("Read timeout"));
|
49 |
+
}, timeout);
|
50 |
+
|
51 |
+
this.reader!.read()
|
52 |
+
.then(({ value, done }) => {
|
53 |
+
clearTimeout(timer);
|
54 |
+
if (done || !value) {
|
55 |
+
reject(new Error("Read failed"));
|
56 |
+
} else {
|
57 |
+
resolve(Buffer.from(value));
|
58 |
+
}
|
59 |
+
})
|
60 |
+
.catch(reject);
|
61 |
+
});
|
62 |
+
}
|
63 |
+
|
64 |
+
async close(): Promise<void> {
|
65 |
+
if (this.reader) {
|
66 |
+
await this.reader.cancel();
|
67 |
+
this.reader = null;
|
68 |
+
}
|
69 |
+
if (this.writer) {
|
70 |
+
this.writer.releaseLock();
|
71 |
+
this.writer = null;
|
72 |
+
}
|
73 |
+
// Don't close the port itself - let the UI manage that
|
74 |
+
}
|
75 |
+
}
|
76 |
+
|
77 |
+
/**
|
78 |
+
* SO-100 calibration configuration for web
|
79 |
+
*/
|
80 |
+
interface WebSO100CalibrationConfig {
|
81 |
+
deviceType: "so100_follower" | "so100_leader";
|
82 |
+
port: WebSerialPortWrapper;
|
83 |
+
motorNames: string[];
|
84 |
+
driveModes: number[];
|
85 |
+
calibModes: string[];
|
86 |
+
limits: {
|
87 |
+
position_min: number[];
|
88 |
+
position_max: number[];
|
89 |
+
velocity_max: number[];
|
90 |
+
torque_max: number[];
|
91 |
+
};
|
92 |
+
}
|
93 |
+
|
94 |
+
/**
|
95 |
+
* Read motor positions using Web Serial API
|
96 |
+
*/
|
97 |
+
async function readMotorPositions(
|
98 |
+
config: WebSO100CalibrationConfig
|
99 |
+
): Promise<number[]> {
|
100 |
+
const motorPositions: number[] = [];
|
101 |
+
const motorIds = [1, 2, 3, 4, 5, 6]; // SO-100 uses servo IDs 1-6
|
102 |
+
|
103 |
+
for (let i = 0; i < motorIds.length; i++) {
|
104 |
+
const motorId = motorIds[i];
|
105 |
+
|
106 |
+
try {
|
107 |
+
// Create STS3215 Read Position packet
|
108 |
+
const packet = Buffer.from([
|
109 |
+
0xff,
|
110 |
+
0xff,
|
111 |
+
motorId,
|
112 |
+
0x04,
|
113 |
+
0x02,
|
114 |
+
0x38,
|
115 |
+
0x02,
|
116 |
+
0x00,
|
117 |
+
]);
|
118 |
+
const checksum = ~(motorId + 0x04 + 0x02 + 0x38 + 0x02) & 0xff;
|
119 |
+
packet[7] = checksum;
|
120 |
+
|
121 |
+
await config.port.write(packet);
|
122 |
+
|
123 |
+
try {
|
124 |
+
const response = await config.port.read(100);
|
125 |
+
if (response.length >= 7) {
|
126 |
+
const id = response[2];
|
127 |
+
const error = response[4];
|
128 |
+
if (id === motorId && error === 0) {
|
129 |
+
const position = response[5] | (response[6] << 8);
|
130 |
+
motorPositions.push(position);
|
131 |
+
} else {
|
132 |
+
motorPositions.push(2047); // Fallback to center
|
133 |
+
}
|
134 |
+
} else {
|
135 |
+
motorPositions.push(2047);
|
136 |
+
}
|
137 |
+
} catch (readError) {
|
138 |
+
motorPositions.push(2047);
|
139 |
+
}
|
140 |
+
} catch (error) {
|
141 |
+
motorPositions.push(2047);
|
142 |
+
}
|
143 |
+
|
144 |
+
// Minimal delay between servo reads
|
145 |
+
await new Promise((resolve) => setTimeout(resolve, 2));
|
146 |
+
}
|
147 |
+
|
148 |
+
return motorPositions;
|
149 |
+
}
|
150 |
+
|
151 |
+
/**
|
152 |
+
* Interactive web calibration with live updates
|
153 |
+
*/
|
154 |
+
async function performWebCalibration(
|
155 |
+
config: WebSO100CalibrationConfig
|
156 |
+
): Promise<any> {
|
157 |
+
// Step 1: Set homing position
|
158 |
+
alert(
|
159 |
+
`π STEP 1: Set Homing Position\n\nMove the SO-100 ${config.deviceType} to the MIDDLE of its range of motion and click OK...`
|
160 |
+
);
|
161 |
+
|
162 |
+
const currentPositions = await readMotorPositions(config);
|
163 |
+
const homingOffsets: { [motor: string]: number } = {};
|
164 |
+
for (let i = 0; i < config.motorNames.length; i++) {
|
165 |
+
const motorName = config.motorNames[i];
|
166 |
+
const position = currentPositions[i];
|
167 |
+
const maxRes = 4095; // STS3215 resolution
|
168 |
+
homingOffsets[motorName] = position - Math.floor(maxRes / 2);
|
169 |
+
}
|
170 |
+
|
171 |
+
// Step 2: Record ranges with simplified interface for web
|
172 |
+
alert(
|
173 |
+
`π STEP 2: Record Joint Ranges\n\nMove all joints through their full range of motion, then click OK when finished...`
|
174 |
+
);
|
175 |
+
|
176 |
+
const rangeMins: { [motor: string]: number } = {};
|
177 |
+
const rangeMaxes: { [motor: string]: number } = {};
|
178 |
+
|
179 |
+
// Initialize with current positions
|
180 |
+
const initialPositions = await readMotorPositions(config);
|
181 |
+
for (let i = 0; i < config.motorNames.length; i++) {
|
182 |
+
const motorName = config.motorNames[i];
|
183 |
+
const position = initialPositions[i];
|
184 |
+
rangeMins[motorName] = position;
|
185 |
+
rangeMaxes[motorName] = position;
|
186 |
+
}
|
187 |
+
|
188 |
+
// Record positions for a brief period
|
189 |
+
const recordingDuration = 10000; // 10 seconds
|
190 |
+
const startTime = Date.now();
|
191 |
+
|
192 |
+
while (Date.now() - startTime < recordingDuration) {
|
193 |
+
const positions = await readMotorPositions(config);
|
194 |
+
|
195 |
+
for (let i = 0; i < config.motorNames.length; i++) {
|
196 |
+
const motorName = config.motorNames[i];
|
197 |
+
const position = positions[i];
|
198 |
+
|
199 |
+
if (position < rangeMins[motorName]) {
|
200 |
+
rangeMins[motorName] = position;
|
201 |
+
}
|
202 |
+
if (position > rangeMaxes[motorName]) {
|
203 |
+
rangeMaxes[motorName] = position;
|
204 |
+
}
|
205 |
+
}
|
206 |
+
|
207 |
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
208 |
+
}
|
209 |
+
|
210 |
+
return {
|
211 |
+
homing_offset: config.motorNames.map((name) => homingOffsets[name]),
|
212 |
+
drive_mode: config.driveModes,
|
213 |
+
start_pos: config.motorNames.map((name) => rangeMins[name]),
|
214 |
+
end_pos: config.motorNames.map((name) => rangeMaxes[name]),
|
215 |
+
calib_mode: config.calibModes,
|
216 |
+
motor_names: config.motorNames,
|
217 |
+
};
|
218 |
+
}
|
219 |
+
|
220 |
+
/**
|
221 |
+
* Calibrate a device using an already connected port
|
222 |
+
*/
|
223 |
+
export async function calibrateWithPort(
|
224 |
+
armType: "so100_follower" | "so100_leader",
|
225 |
+
armId: string,
|
226 |
+
connectedPort: SerialPort
|
227 |
+
): Promise<void> {
|
228 |
+
try {
|
229 |
+
// Create web serial port wrapper
|
230 |
+
const port = new WebSerialPortWrapper(connectedPort);
|
231 |
+
await port.initialize();
|
232 |
+
|
233 |
+
// Get SO-100 calibration configuration
|
234 |
+
const so100Config: WebSO100CalibrationConfig = {
|
235 |
+
deviceType: armType,
|
236 |
+
port,
|
237 |
+
motorNames: [
|
238 |
+
"shoulder_pan",
|
239 |
+
"shoulder_lift",
|
240 |
+
"elbow_flex",
|
241 |
+
"wrist_flex",
|
242 |
+
"wrist_roll",
|
243 |
+
"gripper",
|
244 |
+
],
|
245 |
+
driveModes: [0, 0, 0, 0, 0, 0],
|
246 |
+
calibModes: [
|
247 |
+
"position",
|
248 |
+
"position",
|
249 |
+
"position",
|
250 |
+
"position",
|
251 |
+
"position",
|
252 |
+
"position",
|
253 |
+
],
|
254 |
+
limits: {
|
255 |
+
position_min: [0, 0, 0, 0, 0, 0],
|
256 |
+
position_max: [4095, 4095, 4095, 4095, 4095, 4095],
|
257 |
+
velocity_max: [100, 100, 100, 100, 100, 100],
|
258 |
+
torque_max: [50, 50, 50, 50, 25, 25],
|
259 |
+
},
|
260 |
+
};
|
261 |
+
|
262 |
+
// Perform calibration
|
263 |
+
const calibrationResults = await performWebCalibration(so100Config);
|
264 |
+
|
265 |
+
// Save to browser storage and download
|
266 |
+
const calibrationData = {
|
267 |
+
...calibrationResults,
|
268 |
+
device_type: armType,
|
269 |
+
device_id: armId,
|
270 |
+
calibrated_at: new Date().toISOString(),
|
271 |
+
platform: "web",
|
272 |
+
api: "Web Serial API",
|
273 |
+
};
|
274 |
+
|
275 |
+
const storageKey = `lerobot_calibration_${armType}_${armId}`;
|
276 |
+
localStorage.setItem(storageKey, JSON.stringify(calibrationData));
|
277 |
+
|
278 |
+
// Download calibration file
|
279 |
+
downloadCalibrationFile(calibrationData, armId);
|
280 |
+
|
281 |
+
// Close wrapper (but not the underlying port)
|
282 |
+
await port.close();
|
283 |
+
|
284 |
+
console.log(`Configuration saved to browser storage and downloaded.`);
|
285 |
+
} catch (error) {
|
286 |
+
throw new Error(
|
287 |
+
`Web calibration failed: ${
|
288 |
+
error instanceof Error ? error.message : error
|
289 |
+
}`
|
290 |
+
);
|
291 |
+
}
|
292 |
+
}
|
293 |
+
|
294 |
+
/**
|
295 |
+
* Calibrate a device in the browser using Web Serial API
|
296 |
+
* Must be called from user interaction (button click)
|
297 |
+
* This version requests a new port - use calibrateWithPort for already connected ports
|
298 |
+
*/
|
299 |
+
export async function calibrate(config: CalibrateConfig): Promise<void> {
|
300 |
+
// Validate Web Serial API support
|
301 |
+
if (!("serial" in navigator)) {
|
302 |
+
throw new Error("Web Serial API not supported in this browser");
|
303 |
+
}
|
304 |
+
|
305 |
+
// Validate configuration
|
306 |
+
if (Boolean(config.robot) === Boolean(config.teleop)) {
|
307 |
+
throw new Error("Choose either a robot or a teleop.");
|
308 |
+
}
|
309 |
+
|
310 |
+
const deviceConfig = config.robot || config.teleop!;
|
311 |
+
|
312 |
+
try {
|
313 |
+
// Request a new port for this calibration
|
314 |
+
const port = await navigator.serial.requestPort();
|
315 |
+
await port.open({ baudRate: 1000000 });
|
316 |
+
|
317 |
+
// Use the new port calibration function
|
318 |
+
await calibrateWithPort(
|
319 |
+
deviceConfig.type as "so100_follower" | "so100_leader",
|
320 |
+
deviceConfig.id || deviceConfig.type,
|
321 |
+
port
|
322 |
+
);
|
323 |
+
|
324 |
+
// Close the port we opened
|
325 |
+
await port.close();
|
326 |
+
} catch (error) {
|
327 |
+
throw new Error(
|
328 |
+
`Web calibration failed: ${
|
329 |
+
error instanceof Error ? error.message : error
|
330 |
+
}`
|
331 |
+
);
|
332 |
+
}
|
333 |
+
}
|
334 |
+
|
335 |
+
/**
|
336 |
+
* Download calibration data as JSON file
|
337 |
+
*/
|
338 |
+
function downloadCalibrationFile(calibrationData: any, deviceId: string): void {
|
339 |
+
const dataStr = JSON.stringify(calibrationData, null, 2);
|
340 |
+
const dataBlob = new Blob([dataStr], { type: "application/json" });
|
341 |
+
|
342 |
+
const url = URL.createObjectURL(dataBlob);
|
343 |
+
const link = document.createElement("a");
|
344 |
+
link.href = url;
|
345 |
+
link.download = `${deviceId}_calibration.json`;
|
346 |
+
|
347 |
+
document.body.appendChild(link);
|
348 |
+
link.click();
|
349 |
+
document.body.removeChild(link);
|
350 |
+
URL.revokeObjectURL(url);
|
351 |
+
}
|
352 |
+
|
353 |
+
/**
|
354 |
+
* Check if Web Serial API is supported
|
355 |
+
*/
|
356 |
+
export function isWebSerialSupported(): boolean {
|
357 |
+
return "serial" in navigator;
|
358 |
+
}
|
359 |
+
|
360 |
+
/**
|
361 |
+
* Create a calibration button for web interface
|
362 |
+
* Returns a button element that when clicked starts calibration
|
363 |
+
*/
|
364 |
+
export function createCalibrateButton(
|
365 |
+
config: CalibrateConfig
|
366 |
+
): HTMLButtonElement {
|
367 |
+
const button = document.createElement("button");
|
368 |
+
button.textContent = "Calibrate Device";
|
369 |
+
button.style.cssText = `
|
370 |
+
padding: 10px 20px;
|
371 |
+
background-color: #007bff;
|
372 |
+
color: white;
|
373 |
+
border: none;
|
374 |
+
border-radius: 5px;
|
375 |
+
cursor: pointer;
|
376 |
+
font-size: 16px;
|
377 |
+
`;
|
378 |
+
|
379 |
+
button.addEventListener("click", async () => {
|
380 |
+
button.disabled = true;
|
381 |
+
button.textContent = "Calibrating...";
|
382 |
+
|
383 |
+
try {
|
384 |
+
await calibrate(config);
|
385 |
+
button.textContent = "Calibration Complete!";
|
386 |
+
button.style.backgroundColor = "#28a745";
|
387 |
+
} catch (error) {
|
388 |
+
button.textContent = "Calibration Failed";
|
389 |
+
button.style.backgroundColor = "#dc3545";
|
390 |
+
console.error("Calibration error:", error);
|
391 |
+
alert(
|
392 |
+
`Calibration failed: ${error instanceof Error ? error.message : error}`
|
393 |
+
);
|
394 |
+
} finally {
|
395 |
+
setTimeout(() => {
|
396 |
+
button.disabled = false;
|
397 |
+
button.textContent = "Calibrate Device";
|
398 |
+
button.style.backgroundColor = "#007bff";
|
399 |
+
}, 3000);
|
400 |
+
}
|
401 |
+
});
|
402 |
+
|
403 |
+
return button;
|
404 |
+
}
|
src/lerobot/web/robots/robot.ts
ADDED
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Base Robot class for Web platform
|
3 |
+
* Uses Web Serial API for serial communication
|
4 |
+
* Mirrors Python lerobot/common/robots/robot.py but adapted for browser environment
|
5 |
+
*/
|
6 |
+
|
7 |
+
import type { RobotConfig } from "../../node/robots/config.js";
|
8 |
+
|
9 |
+
// Web Serial API type declarations (minimal for our needs)
|
10 |
+
declare global {
|
11 |
+
interface SerialPort {
|
12 |
+
open(options: { baudRate: number }): Promise<void>;
|
13 |
+
close(): Promise<void>;
|
14 |
+
readable: ReadableStream<Uint8Array> | null;
|
15 |
+
writable: WritableStream<Uint8Array> | null;
|
16 |
+
}
|
17 |
+
}
|
18 |
+
|
19 |
+
export abstract class Robot {
|
20 |
+
protected port: SerialPort | null = null;
|
21 |
+
protected config: RobotConfig;
|
22 |
+
protected name: string;
|
23 |
+
protected reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
24 |
+
protected writer: WritableStreamDefaultWriter<Uint8Array> | null = null;
|
25 |
+
|
26 |
+
constructor(config: RobotConfig) {
|
27 |
+
this.config = config;
|
28 |
+
this.name = config.type;
|
29 |
+
}
|
30 |
+
|
31 |
+
/**
|
32 |
+
* Connect to the robot using Web Serial API
|
33 |
+
* Requires user interaction to select port
|
34 |
+
*/
|
35 |
+
async connect(_calibrate: boolean = false): Promise<void> {
|
36 |
+
try {
|
37 |
+
// Request port from user (requires user interaction)
|
38 |
+
this.port = await navigator.serial.requestPort();
|
39 |
+
|
40 |
+
// Open the port with correct SO-100 baudRate
|
41 |
+
await this.port.open({ baudRate: 1000000 }); // Correct baudRate for Feetech motors (SO-100)
|
42 |
+
|
43 |
+
// Set up readable and writable streams
|
44 |
+
if (this.port.readable) {
|
45 |
+
this.reader = this.port.readable.getReader();
|
46 |
+
}
|
47 |
+
|
48 |
+
if (this.port.writable) {
|
49 |
+
this.writer = this.port.writable.getWriter();
|
50 |
+
}
|
51 |
+
} catch (error) {
|
52 |
+
throw new Error(
|
53 |
+
`Could not connect to robot: ${
|
54 |
+
error instanceof Error ? error.message : error
|
55 |
+
}`
|
56 |
+
);
|
57 |
+
}
|
58 |
+
}
|
59 |
+
|
60 |
+
/**
|
61 |
+
* Calibrate the robot
|
62 |
+
* Must be implemented by subclasses
|
63 |
+
*/
|
64 |
+
abstract calibrate(): Promise<void>;
|
65 |
+
|
66 |
+
/**
|
67 |
+
* Disconnect from the robot
|
68 |
+
*/
|
69 |
+
async disconnect(): Promise<void> {
|
70 |
+
if (this.reader) {
|
71 |
+
await this.reader.cancel();
|
72 |
+
this.reader.releaseLock();
|
73 |
+
this.reader = null;
|
74 |
+
}
|
75 |
+
|
76 |
+
if (this.writer) {
|
77 |
+
await this.writer.close();
|
78 |
+
this.writer = null;
|
79 |
+
}
|
80 |
+
|
81 |
+
if (this.port) {
|
82 |
+
await this.port.close();
|
83 |
+
this.port = null;
|
84 |
+
}
|
85 |
+
}
|
86 |
+
|
87 |
+
/**
|
88 |
+
* Save calibration data to browser storage
|
89 |
+
* Uses localStorage as fallback, IndexedDB preferred for larger data
|
90 |
+
*/
|
91 |
+
protected async saveCalibration(calibrationData: any): Promise<void> {
|
92 |
+
const robotId = this.config.id || this.name;
|
93 |
+
const key = `lerobot_calibration_${this.name}_${robotId}`;
|
94 |
+
|
95 |
+
try {
|
96 |
+
// Save to localStorage for now (could be enhanced to use File System Access API)
|
97 |
+
localStorage.setItem(key, JSON.stringify(calibrationData));
|
98 |
+
|
99 |
+
// Optionally trigger download
|
100 |
+
this.downloadCalibration(calibrationData, robotId);
|
101 |
+
|
102 |
+
console.log(`Configuration saved to browser storage and downloaded.`);
|
103 |
+
} catch (error) {
|
104 |
+
this.downloadCalibration(calibrationData, robotId);
|
105 |
+
console.log(`Configuration downloaded as file.`);
|
106 |
+
}
|
107 |
+
}
|
108 |
+
|
109 |
+
/**
|
110 |
+
* Download calibration data as JSON file
|
111 |
+
*/
|
112 |
+
private downloadCalibration(calibrationData: any, robotId: string): void {
|
113 |
+
const dataStr = JSON.stringify(calibrationData, null, 2);
|
114 |
+
const dataBlob = new Blob([dataStr], { type: "application/json" });
|
115 |
+
|
116 |
+
const url = URL.createObjectURL(dataBlob);
|
117 |
+
const link = document.createElement("a");
|
118 |
+
link.href = url;
|
119 |
+
link.download = `${robotId}_calibration.json`;
|
120 |
+
|
121 |
+
document.body.appendChild(link);
|
122 |
+
link.click();
|
123 |
+
document.body.removeChild(link);
|
124 |
+
URL.revokeObjectURL(url);
|
125 |
+
}
|
126 |
+
|
127 |
+
/**
|
128 |
+
* Send command to robot via Web Serial API
|
129 |
+
*/
|
130 |
+
protected async sendCommand(command: string): Promise<void> {
|
131 |
+
if (!this.writer) {
|
132 |
+
throw new Error("Robot not connected");
|
133 |
+
}
|
134 |
+
|
135 |
+
const encoder = new TextEncoder();
|
136 |
+
const data = encoder.encode(command);
|
137 |
+
await this.writer.write(data);
|
138 |
+
}
|
139 |
+
|
140 |
+
/**
|
141 |
+
* Read data from robot with timeout
|
142 |
+
*/
|
143 |
+
protected async readData(timeout: number = 5000): Promise<Uint8Array> {
|
144 |
+
if (!this.reader) {
|
145 |
+
throw new Error("Robot not connected");
|
146 |
+
}
|
147 |
+
|
148 |
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
149 |
+
setTimeout(() => reject(new Error("Read timeout")), timeout);
|
150 |
+
});
|
151 |
+
|
152 |
+
const readPromise = this.reader.read().then((result) => {
|
153 |
+
if (result.done) {
|
154 |
+
throw new Error("Stream closed");
|
155 |
+
}
|
156 |
+
return result.value;
|
157 |
+
});
|
158 |
+
|
159 |
+
return Promise.race([readPromise, timeoutPromise]);
|
160 |
+
}
|
161 |
+
|
162 |
+
/**
|
163 |
+
* Disable torque on disconnect (SO-100 specific)
|
164 |
+
*/
|
165 |
+
protected async disableTorque(): Promise<void> {
|
166 |
+
try {
|
167 |
+
await this.sendCommand("TORQUE_DISABLE\r\n");
|
168 |
+
} catch (error) {
|
169 |
+
console.warn("Warning: Could not disable torque on disconnect");
|
170 |
+
}
|
171 |
+
}
|
172 |
+
}
|
src/main.ts
CHANGED
@@ -2,203 +2,552 @@
|
|
2 |
* lerobot.js Web Interface
|
3 |
*
|
4 |
* Browser-based interface for lerobot functionality
|
5 |
-
* Provides
|
6 |
*/
|
7 |
|
8 |
import "./web_interface.css";
|
9 |
import { findPortWeb } from "./lerobot/web/find_port.js";
|
|
|
|
|
|
|
|
|
10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
11 |
document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
|
12 |
-
<div
|
13 |
-
<
|
14 |
-
|
15 |
-
|
16 |
-
|
17 |
|
18 |
-
<
|
19 |
-
|
20 |
-
|
21 |
-
<
|
22 |
-
<
|
23 |
-
|
24 |
-
|
25 |
-
<button id="
|
|
|
|
|
|
|
|
|
|
|
|
|
26 |
</div>
|
27 |
-
|
28 |
-
|
29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
30 |
</div>
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
<ul>
|
38 |
-
<li>Chrome/Edge 89+ or Chrome Android 105+</li>
|
39 |
-
<li>HTTPS connection (or localhost for development)</li>
|
40 |
-
</ul>
|
41 |
-
<p>Alternatively, use the <strong>CLI version</strong>: <code>npx lerobot find-port</code></p>
|
42 |
-
</section>
|
43 |
-
</main>
|
44 |
</div>
|
45 |
`;
|
46 |
|
47 |
-
//
|
48 |
-
const
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
57 |
|
58 |
-
//
|
59 |
-
async function
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
60 |
try {
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
|
67 |
-
return;
|
68 |
-
}
|
69 |
|
70 |
-
|
71 |
-
|
72 |
-
|
73 |
-
|
74 |
-
// We have paired devices, show them
|
75 |
-
resultsArea.innerHTML = `<p class="success">Found ${ports.length} paired device(s):</p>`;
|
76 |
-
ports.forEach((port, index) => {
|
77 |
-
const info = port.getInfo();
|
78 |
-
if (info.usbVendorId && info.usbProductId) {
|
79 |
-
resultsArea.innerHTML += `<p class="log">Port ${index + 1}: USB:${
|
80 |
-
info.usbVendorId
|
81 |
-
}:${info.usbProductId}</p>`;
|
82 |
-
} else {
|
83 |
-
resultsArea.innerHTML += `<p class="log">Port ${
|
84 |
-
index + 1
|
85 |
-
}: Serial device</p>`;
|
86 |
}
|
87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
88 |
|
89 |
-
|
90 |
-
|
91 |
|
92 |
-
|
93 |
-
|
94 |
-
|
95 |
-
|
96 |
-
|
97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
98 |
|
99 |
-
|
100 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
101 |
|
102 |
-
|
103 |
-
|
|
|
|
|
104 |
}
|
105 |
} catch (error) {
|
106 |
-
|
107 |
-
|
108 |
-
|
109 |
-
|
110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
111 |
}
|
|
|
|
|
|
|
112 |
}
|
113 |
|
114 |
-
//
|
115 |
-
function
|
116 |
-
const
|
117 |
-
"#compatibility-section"
|
118 |
-
) as HTMLElement;
|
119 |
|
120 |
-
|
121 |
-
|
122 |
-
|
|
|
|
|
|
|
123 |
} else {
|
124 |
-
|
125 |
-
|
|
|
|
|
|
|
|
|
|
|
126 |
}
|
127 |
}
|
128 |
|
129 |
-
//
|
130 |
-
|
131 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
132 |
|
133 |
-
|
134 |
-
showPortsBtn.addEventListener("click", async () => {
|
135 |
try {
|
136 |
-
|
137 |
-
|
138 |
-
|
139 |
-
|
140 |
-
|
141 |
-
|
142 |
-
await navigator.serial.requestPort();
|
143 |
-
// Refresh the display
|
144 |
-
await displayPairedDevices();
|
145 |
-
} catch (permissionError) {
|
146 |
-
console.log("Permission dialog cancelled:", permissionError);
|
147 |
-
}
|
148 |
} catch (error) {
|
149 |
-
|
150 |
-
|
151 |
-
|
152 |
-
|
153 |
-
showPortsBtn.disabled = false;
|
154 |
-
showPortsBtn.textContent = "Show Available Ports";
|
155 |
}
|
156 |
-
}
|
157 |
|
158 |
-
|
159 |
-
manageDevicesBtn.addEventListener("click", async () => {
|
160 |
try {
|
161 |
-
|
162 |
-
|
163 |
-
|
164 |
-
|
165 |
-
|
166 |
-
await navigator.serial.requestPort();
|
167 |
-
// Refresh the display to show updated device list
|
168 |
-
await displayPairedDevices();
|
169 |
-
resultsArea.innerHTML +=
|
170 |
-
'<p class="success">Device pairing completed. Updated device list above.</p>';
|
171 |
-
} catch (permissionError) {
|
172 |
-
console.log("Permission dialog cancelled:", permissionError);
|
173 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
174 |
} catch (error) {
|
175 |
-
|
176 |
-
|
177 |
-
|
178 |
-
|
179 |
-
manageDevicesBtn.disabled = false;
|
180 |
-
manageDevicesBtn.textContent = "Manage Devices";
|
181 |
-
resultsArea.scrollTop = resultsArea.scrollHeight;
|
182 |
}
|
183 |
-
}
|
|
|
|
|
|
|
|
|
|
|
184 |
|
185 |
-
|
186 |
-
findPortBtn.addEventListener("click", async () => {
|
187 |
try {
|
188 |
-
|
189 |
-
|
190 |
-
resultsArea.innerHTML = '<p class="status">Starting port detection...</p>';
|
191 |
|
|
|
|
|
|
|
|
|
192 |
await findPortWeb((message: string) => {
|
193 |
-
|
194 |
-
resultsArea.scrollTop = resultsArea.scrollHeight;
|
195 |
});
|
196 |
} catch (error) {
|
197 |
-
|
198 |
-
|
199 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
200 |
} finally {
|
201 |
-
|
202 |
-
|
203 |
}
|
204 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2 |
* lerobot.js Web Interface
|
3 |
*
|
4 |
* Browser-based interface for lerobot functionality
|
5 |
+
* Provides port connection and calibration functionality
|
6 |
*/
|
7 |
|
8 |
import "./web_interface.css";
|
9 |
import { findPortWeb } from "./lerobot/web/find_port.js";
|
10 |
+
import {
|
11 |
+
calibrateWithPort,
|
12 |
+
isWebSerialSupported,
|
13 |
+
} from "./lerobot/web/calibrate.js";
|
14 |
|
15 |
+
// Extend SerialPort interface for missing methods
|
16 |
+
declare global {
|
17 |
+
interface SerialPort {
|
18 |
+
getInfo(): { usbVendorId?: number; usbProductId?: number };
|
19 |
+
}
|
20 |
+
}
|
21 |
+
|
22 |
+
// Store connected ports with connection state
|
23 |
+
let connectedPorts: { port: SerialPort; name: string; isConnected: boolean }[] =
|
24 |
+
[];
|
25 |
+
|
26 |
+
// Store original console.log before override
|
27 |
+
const originalConsoleLog = console.log;
|
28 |
+
|
29 |
+
// Main application setup
|
30 |
document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
|
31 |
+
<div>
|
32 |
+
<h1>LeRobot.js Demo</h1>
|
33 |
+
<p>Web Serial API implementation for robot calibration and control</p>
|
34 |
+
|
35 |
+
<div id="serial-support" class="status"></div>
|
36 |
|
37 |
+
<div class="cards-container">
|
38 |
+
<!-- Port Connection Card -->
|
39 |
+
<div class="card">
|
40 |
+
<h2>π Port Connection</h2>
|
41 |
+
<p>Connect to your robot arms to test communication</p>
|
42 |
+
|
43 |
+
<div class="controls">
|
44 |
+
<button id="connect-port">Connect to Port</button>
|
45 |
+
<button id="find-ports">Find Available Ports</button>
|
46 |
+
</div>
|
47 |
+
|
48 |
+
<div id="connected-ports">
|
49 |
+
<h3>Connected Ports:</h3>
|
50 |
+
<div id="ports-list">No ports connected</div>
|
51 |
</div>
|
52 |
+
|
53 |
+
<div id="find-ports-log"></div>
|
54 |
+
</div>
|
55 |
+
|
56 |
+
<!-- Robot Calibration Card -->
|
57 |
+
<div class="card">
|
58 |
+
<h2>π€ Robot Calibration</h2>
|
59 |
+
<p>Calibrate your SO-100 robot arms</p>
|
60 |
+
|
61 |
+
<div class="controls">
|
62 |
+
<div class="form-group">
|
63 |
+
<label for="arm-type">Arm Type:</label>
|
64 |
+
<select id="arm-type">
|
65 |
+
<option value="so100_follower">SO-100 Follower (Robot)</option>
|
66 |
+
<option value="so100_leader">SO-100 Leader (Teleoperator)</option>
|
67 |
+
</select>
|
68 |
+
</div>
|
69 |
+
|
70 |
+
<div class="form-group">
|
71 |
+
<label for="port-select">Select Port:</label>
|
72 |
+
<select id="port-select">
|
73 |
+
<option value="">No ports connected</option>
|
74 |
+
</select>
|
75 |
+
</div>
|
76 |
+
|
77 |
+
<div class="form-group">
|
78 |
+
<label for="arm-id">Arm ID:</label>
|
79 |
+
<input type="text" id="arm-id" placeholder="e.g., my_robot" value="demo_arm">
|
80 |
+
</div>
|
81 |
+
|
82 |
+
<button id="start-calibration" disabled>Start Calibration</button>
|
83 |
</div>
|
84 |
+
|
85 |
+
<div id="calibration-status"></div>
|
86 |
+
</div>
|
87 |
+
</div>
|
88 |
+
|
89 |
+
<div id="log"></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
90 |
</div>
|
91 |
`;
|
92 |
|
93 |
+
// Add CSS for cards
|
94 |
+
const style = document.createElement("style");
|
95 |
+
style.textContent = `
|
96 |
+
.cards-container {
|
97 |
+
display: grid;
|
98 |
+
grid-template-columns: 1fr 1fr;
|
99 |
+
gap: 2rem;
|
100 |
+
margin: 2rem 0;
|
101 |
+
}
|
102 |
+
|
103 |
+
.card {
|
104 |
+
border: 1px solid #ddd;
|
105 |
+
border-radius: 8px;
|
106 |
+
padding: 1.5rem;
|
107 |
+
background: #f9f9f9;
|
108 |
+
}
|
109 |
+
|
110 |
+
.card h2 {
|
111 |
+
margin-top: 0;
|
112 |
+
color: #333;
|
113 |
+
}
|
114 |
+
|
115 |
+
.form-group {
|
116 |
+
margin: 1rem 0;
|
117 |
+
}
|
118 |
+
|
119 |
+
.form-group label {
|
120 |
+
display: block;
|
121 |
+
margin-bottom: 0.5rem;
|
122 |
+
font-weight: bold;
|
123 |
+
}
|
124 |
+
|
125 |
+
.form-group select,
|
126 |
+
.form-group input {
|
127 |
+
width: 100%;
|
128 |
+
padding: 0.5rem;
|
129 |
+
border: 1px solid #ccc;
|
130 |
+
border-radius: 4px;
|
131 |
+
font-size: 1rem;
|
132 |
+
}
|
133 |
+
|
134 |
+
.controls button {
|
135 |
+
margin: 0.5rem 0.5rem 0.5rem 0;
|
136 |
+
}
|
137 |
+
|
138 |
+
#connected-ports {
|
139 |
+
margin: 1rem 0;
|
140 |
+
padding: 1rem;
|
141 |
+
background: white;
|
142 |
+
border-radius: 4px;
|
143 |
+
}
|
144 |
+
|
145 |
+
.port-item {
|
146 |
+
display: flex;
|
147 |
+
justify-content: space-between;
|
148 |
+
align-items: center;
|
149 |
+
padding: 0.5rem;
|
150 |
+
margin: 0.5rem 0;
|
151 |
+
background: #e9f5ff;
|
152 |
+
border-radius: 4px;
|
153 |
+
}
|
154 |
+
|
155 |
+
.port-item button {
|
156 |
+
background: #dc3545;
|
157 |
+
color: white;
|
158 |
+
border: none;
|
159 |
+
padding: 0.25rem 0.5rem;
|
160 |
+
border-radius: 3px;
|
161 |
+
cursor: pointer;
|
162 |
+
font-size: 0.875rem;
|
163 |
+
margin-left: 0.25rem;
|
164 |
+
}
|
165 |
+
|
166 |
+
.port-buttons {
|
167 |
+
display: flex;
|
168 |
+
gap: 0.25rem;
|
169 |
+
}
|
170 |
+
|
171 |
+
.port-buttons button:not(.unpair-btn) {
|
172 |
+
background: #007bff;
|
173 |
+
}
|
174 |
+
|
175 |
+
.unpair-btn {
|
176 |
+
background: #dc3545 !important;
|
177 |
+
}
|
178 |
+
`;
|
179 |
+
document.head.appendChild(style);
|
180 |
|
181 |
+
// Initialize the application
|
182 |
+
async function initializeApp() {
|
183 |
+
// Check Web Serial API support
|
184 |
+
const supportDiv = document.querySelector<HTMLDivElement>("#serial-support")!;
|
185 |
+
if (isWebSerialSupported()) {
|
186 |
+
supportDiv.innerHTML = `
|
187 |
+
<div class="success">β
Web Serial API is supported in this browser</div>
|
188 |
+
`;
|
189 |
+
|
190 |
+
// Restore previously connected ports on page load
|
191 |
+
await restoreConnectedPorts();
|
192 |
+
} else {
|
193 |
+
supportDiv.innerHTML = `
|
194 |
+
<div class="error">β Web Serial API is not supported. Please use Chrome/Edge with experimental features enabled.</div>
|
195 |
+
`;
|
196 |
+
}
|
197 |
+
}
|
198 |
+
|
199 |
+
// Restore connected ports from browser's serial port list
|
200 |
+
async function restoreConnectedPorts() {
|
201 |
try {
|
202 |
+
const existingPorts = await navigator.serial.getPorts();
|
203 |
+
|
204 |
+
for (const port of existingPorts) {
|
205 |
+
// Try to reconnect to previously permitted ports
|
206 |
+
let isConnected = false;
|
207 |
+
const portName = getPortDisplayName(port);
|
|
|
|
|
208 |
|
209 |
+
try {
|
210 |
+
// Check if port is already open, if not, open it
|
211 |
+
if (!port.readable || !port.writable) {
|
212 |
+
await port.open({ baudRate: 1000000 });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
213 |
}
|
214 |
+
isConnected = true;
|
215 |
+
originalConsoleLog(`Restored connection to: ${portName}`);
|
216 |
+
} catch (error) {
|
217 |
+
originalConsoleLog(`Could not reconnect to port ${portName}:`, error);
|
218 |
+
// Port might be in use by another application or disconnected
|
219 |
+
// But we still add it to the list so user can see it's paired
|
220 |
+
}
|
221 |
|
222 |
+
connectedPorts.push({ port, name: portName, isConnected });
|
223 |
+
}
|
224 |
|
225 |
+
if (connectedPorts.length > 0) {
|
226 |
+
const connectedCount = connectedPorts.filter((p) => p.isConnected).length;
|
227 |
+
originalConsoleLog(
|
228 |
+
`Found ${connectedPorts.length} paired ports, ${connectedCount} connected`
|
229 |
+
);
|
230 |
+
updatePortsList();
|
231 |
+
updateCalibrationButton();
|
232 |
+
}
|
233 |
+
} catch (error) {
|
234 |
+
originalConsoleLog("Could not restore ports:", error);
|
235 |
+
}
|
236 |
+
}
|
237 |
+
|
238 |
+
// Get a meaningful display name for a port
|
239 |
+
function getPortDisplayName(port: SerialPort): string {
|
240 |
+
// Use original console.log to avoid showing debug info in the page UI
|
241 |
+
originalConsoleLog("=== PORT DEBUG INFO ===");
|
242 |
+
originalConsoleLog("Full port object:", port);
|
243 |
+
originalConsoleLog("Port readable:", port.readable);
|
244 |
+
originalConsoleLog("Port writable:", port.writable);
|
245 |
+
|
246 |
+
try {
|
247 |
+
const info = port.getInfo();
|
248 |
+
originalConsoleLog("Port getInfo() result:", info);
|
249 |
+
originalConsoleLog("USB Vendor ID:", info.usbVendorId);
|
250 |
+
originalConsoleLog("USB Product ID:", info.usbProductId);
|
251 |
|
252 |
+
// Log all properties of the info object
|
253 |
+
originalConsoleLog("All info properties:");
|
254 |
+
for (const [key, value] of Object.entries(info)) {
|
255 |
+
originalConsoleLog(` ${key}:`, value);
|
256 |
+
}
|
257 |
+
|
258 |
+
// Try to extract port name from port info
|
259 |
+
if (info.usbVendorId && info.usbProductId) {
|
260 |
+
return `USB Port (${info.usbVendorId}:${info.usbProductId})`;
|
261 |
+
}
|
262 |
|
263 |
+
// For Windows COM ports, we can't get the exact name from Web Serial API
|
264 |
+
// but we can show some identifying information
|
265 |
+
if (info.usbVendorId) {
|
266 |
+
return `Serial Port (VID:${info.usbVendorId.toString(16).toUpperCase()})`;
|
267 |
}
|
268 |
} catch (error) {
|
269 |
+
// getInfo() might not be available in all browsers
|
270 |
+
originalConsoleLog("Port info not available:", error);
|
271 |
+
}
|
272 |
+
|
273 |
+
originalConsoleLog("=== END PORT DEBUG ===");
|
274 |
+
|
275 |
+
// Fallback to generic name with unique identifier
|
276 |
+
const portIndex = connectedPorts.length;
|
277 |
+
return `Serial Port ${portIndex + 1}`;
|
278 |
+
}
|
279 |
+
|
280 |
+
// Simple port connection functionality (restored)
|
281 |
+
const connectPortBtn =
|
282 |
+
document.querySelector<HTMLButtonElement>("#connect-port")!;
|
283 |
+
const portsListDiv = document.querySelector<HTMLDivElement>("#ports-list")!;
|
284 |
+
|
285 |
+
connectPortBtn.addEventListener("click", async () => {
|
286 |
+
try {
|
287 |
+
connectPortBtn.disabled = true;
|
288 |
+
connectPortBtn.textContent = "Connecting...";
|
289 |
+
|
290 |
+
// Simple port connection dialog
|
291 |
+
const port = await navigator.serial.requestPort();
|
292 |
+
await port.open({ baudRate: 1000000 });
|
293 |
+
|
294 |
+
// Add to connected ports with meaningful name
|
295 |
+
const portName = getPortDisplayName(port);
|
296 |
+
connectedPorts.push({ port, name: portName, isConnected: true });
|
297 |
+
|
298 |
+
updatePortsList();
|
299 |
+
updateCalibrationButton();
|
300 |
+
|
301 |
+
connectPortBtn.textContent = "Connect to Port";
|
302 |
+
} catch (error) {
|
303 |
+
alert(
|
304 |
+
`Failed to connect to port: ${
|
305 |
+
error instanceof Error ? error.message : error
|
306 |
+
}`
|
307 |
+
);
|
308 |
+
connectPortBtn.textContent = "Connect to Port";
|
309 |
+
} finally {
|
310 |
+
connectPortBtn.disabled = false;
|
311 |
+
}
|
312 |
+
});
|
313 |
+
|
314 |
+
// Update connected ports display
|
315 |
+
function updatePortsList() {
|
316 |
+
if (connectedPorts.length === 0) {
|
317 |
+
portsListDiv.innerHTML = "No ports paired";
|
318 |
+
} else {
|
319 |
+
portsListDiv.innerHTML = connectedPorts
|
320 |
+
.map(
|
321 |
+
(portInfo, index) => `
|
322 |
+
<div class="port-item">
|
323 |
+
<span>${portInfo.name} ${portInfo.isConnected ? "π’" : "π΄"}</span>
|
324 |
+
<div class="port-buttons">
|
325 |
+
${
|
326 |
+
portInfo.isConnected
|
327 |
+
? `<button onclick="disconnectPort(${index})">Disconnect</button>`
|
328 |
+
: `<button onclick="reconnectPort(${index})">Connect</button>`
|
329 |
+
}
|
330 |
+
<button onclick="unpairPort(${index})" class="unpair-btn">Unpair</button>
|
331 |
+
</div>
|
332 |
+
</div>
|
333 |
+
`
|
334 |
+
)
|
335 |
+
.join("");
|
336 |
}
|
337 |
+
|
338 |
+
// Update port selector dropdown (only show connected ports for calibration)
|
339 |
+
updatePortSelector();
|
340 |
}
|
341 |
|
342 |
+
// Update port selector dropdown
|
343 |
+
function updatePortSelector() {
|
344 |
+
const portSelect = document.querySelector<HTMLSelectElement>("#port-select")!;
|
|
|
|
|
345 |
|
346 |
+
const connectedOnly = connectedPorts.filter(
|
347 |
+
(portInfo) => portInfo.isConnected
|
348 |
+
);
|
349 |
+
|
350 |
+
if (connectedOnly.length === 0) {
|
351 |
+
portSelect.innerHTML = '<option value="">No ports connected</option>';
|
352 |
} else {
|
353 |
+
portSelect.innerHTML = connectedOnly
|
354 |
+
.map((portInfo, connectedIndex) => {
|
355 |
+
// Find the original index in the full connectedPorts array
|
356 |
+
const originalIndex = connectedPorts.findIndex((p) => p === portInfo);
|
357 |
+
return `<option value="${originalIndex}">${portInfo.name}</option>`;
|
358 |
+
})
|
359 |
+
.join("");
|
360 |
}
|
361 |
}
|
362 |
|
363 |
+
// Make port management functions global
|
364 |
+
(window as any).disconnectPort = async (index: number) => {
|
365 |
+
try {
|
366 |
+
const portInfo = connectedPorts[index];
|
367 |
+
await portInfo.port.close();
|
368 |
+
connectedPorts[index].isConnected = false;
|
369 |
+
updatePortsList();
|
370 |
+
updateCalibrationButton();
|
371 |
+
originalConsoleLog(`Disconnected from ${portInfo.name}`);
|
372 |
+
} catch (error) {
|
373 |
+
console.error("Failed to disconnect port:", error);
|
374 |
+
}
|
375 |
+
};
|
376 |
|
377 |
+
(window as any).reconnectPort = async (index: number) => {
|
|
|
378 |
try {
|
379 |
+
const portInfo = connectedPorts[index];
|
380 |
+
await portInfo.port.open({ baudRate: 1000000 });
|
381 |
+
connectedPorts[index].isConnected = true;
|
382 |
+
updatePortsList();
|
383 |
+
updateCalibrationButton();
|
384 |
+
originalConsoleLog(`Reconnected to ${portInfo.name}`);
|
|
|
|
|
|
|
|
|
|
|
|
|
385 |
} catch (error) {
|
386 |
+
console.error("Failed to reconnect to port:", error);
|
387 |
+
alert(
|
388 |
+
`Failed to reconnect: ${error instanceof Error ? error.message : error}`
|
389 |
+
);
|
|
|
|
|
390 |
}
|
391 |
+
};
|
392 |
|
393 |
+
(window as any).unpairPort = async (index: number) => {
|
|
|
394 |
try {
|
395 |
+
const portInfo = connectedPorts[index];
|
396 |
+
|
397 |
+
// Close the port first if it's connected
|
398 |
+
if (portInfo.isConnected) {
|
399 |
+
await portInfo.port.close();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
400 |
}
|
401 |
+
|
402 |
+
// Try to forget the port (requires newer browsers)
|
403 |
+
if ("forget" in portInfo.port) {
|
404 |
+
await (portInfo.port as any).forget();
|
405 |
+
originalConsoleLog(`Unpaired ${portInfo.name}`);
|
406 |
+
} else {
|
407 |
+
// Fallback for browsers that don't support forget()
|
408 |
+
originalConsoleLog(
|
409 |
+
`Browser doesn't support forget() - manually revoke in browser settings`
|
410 |
+
);
|
411 |
+
alert(
|
412 |
+
"This browser doesn't support automatic unpairing. Please revoke access manually in browser settings (Privacy & Security > Site Settings > Serial Ports)"
|
413 |
+
);
|
414 |
+
}
|
415 |
+
|
416 |
+
// Remove from our list
|
417 |
+
connectedPorts.splice(index, 1);
|
418 |
+
updatePortsList();
|
419 |
+
updateCalibrationButton();
|
420 |
} catch (error) {
|
421 |
+
console.error("Failed to unpair port:", error);
|
422 |
+
alert(
|
423 |
+
`Failed to unpair: ${error instanceof Error ? error.message : error}`
|
424 |
+
);
|
|
|
|
|
|
|
425 |
}
|
426 |
+
};
|
427 |
+
|
428 |
+
// Set up find ports functionality (restored original)
|
429 |
+
const findPortsBtn = document.querySelector<HTMLButtonElement>("#find-ports")!;
|
430 |
+
const findPortsLogDiv =
|
431 |
+
document.querySelector<HTMLDivElement>("#find-ports-log")!;
|
432 |
|
433 |
+
findPortsBtn.addEventListener("click", async () => {
|
|
|
434 |
try {
|
435 |
+
findPortsBtn.disabled = true;
|
436 |
+
findPortsBtn.textContent = "Finding ports...";
|
|
|
437 |
|
438 |
+
// Clear previous results
|
439 |
+
findPortsLogDiv.innerHTML = '<div class="status">Finding ports...</div>';
|
440 |
+
|
441 |
+
// Use the web find port functionality
|
442 |
await findPortWeb((message: string) => {
|
443 |
+
findPortsLogDiv.innerHTML += `<div class="log-entry">${message}</div>`;
|
|
|
444 |
});
|
445 |
} catch (error) {
|
446 |
+
// Check if user cancelled the dialog
|
447 |
+
if (
|
448 |
+
error instanceof Error &&
|
449 |
+
(error.message.includes("cancelled") ||
|
450 |
+
error.message.includes("canceled") ||
|
451 |
+
error.name === "NotAllowedError" ||
|
452 |
+
error.name === "AbortError")
|
453 |
+
) {
|
454 |
+
// User cancelled - just log it, no UI message
|
455 |
+
console.log("Find ports cancelled by user");
|
456 |
+
} else {
|
457 |
+
// Real error - show it
|
458 |
+
findPortsLogDiv.innerHTML = `
|
459 |
+
<div class="error">Error finding ports: ${
|
460 |
+
error instanceof Error ? error.message : error
|
461 |
+
}</div>
|
462 |
+
`;
|
463 |
+
}
|
464 |
} finally {
|
465 |
+
findPortsBtn.disabled = false;
|
466 |
+
findPortsBtn.textContent = "Find Available Ports";
|
467 |
}
|
468 |
});
|
469 |
+
|
470 |
+
// Calibration functionality
|
471 |
+
const armTypeSelect = document.querySelector<HTMLSelectElement>("#arm-type")!;
|
472 |
+
const portSelect = document.querySelector<HTMLSelectElement>("#port-select")!;
|
473 |
+
const armIdInput = document.querySelector<HTMLInputElement>("#arm-id")!;
|
474 |
+
const startCalibrationBtn =
|
475 |
+
document.querySelector<HTMLButtonElement>("#start-calibration")!;
|
476 |
+
const calibrationStatusDiv = document.querySelector<HTMLDivElement>(
|
477 |
+
"#calibration-status"
|
478 |
+
)!;
|
479 |
+
|
480 |
+
function updateCalibrationButton() {
|
481 |
+
const hasConnectedPorts = connectedPorts.some((port) => port.isConnected);
|
482 |
+
startCalibrationBtn.disabled = !hasConnectedPorts;
|
483 |
+
|
484 |
+
if (!hasConnectedPorts) {
|
485 |
+
startCalibrationBtn.textContent = "Connect a port first";
|
486 |
+
} else {
|
487 |
+
startCalibrationBtn.textContent = "Start Calibration";
|
488 |
+
}
|
489 |
+
}
|
490 |
+
|
491 |
+
startCalibrationBtn.addEventListener("click", async () => {
|
492 |
+
try {
|
493 |
+
startCalibrationBtn.disabled = true;
|
494 |
+
startCalibrationBtn.textContent = "Calibrating...";
|
495 |
+
calibrationStatusDiv.innerHTML =
|
496 |
+
'<div class="status">Starting calibration...</div>';
|
497 |
+
|
498 |
+
const armType = armTypeSelect.value as "so100_follower" | "so100_leader";
|
499 |
+
const portIndexStr = portSelect.value;
|
500 |
+
const armId = armIdInput.value.trim() || "demo_arm";
|
501 |
+
|
502 |
+
// Validate port selection
|
503 |
+
if (portIndexStr === "" || connectedPorts.length === 0) {
|
504 |
+
throw new Error("No port selected");
|
505 |
+
}
|
506 |
+
|
507 |
+
const portIndex = parseInt(portIndexStr);
|
508 |
+
if (portIndex < 0 || portIndex >= connectedPorts.length) {
|
509 |
+
throw new Error("Invalid port selection");
|
510 |
+
}
|
511 |
+
|
512 |
+
const selectedPortInfo = connectedPorts[portIndex];
|
513 |
+
if (!selectedPortInfo.isConnected) {
|
514 |
+
throw new Error("Selected port is not connected");
|
515 |
+
}
|
516 |
+
|
517 |
+
const selectedPort = selectedPortInfo.port;
|
518 |
+
await calibrateWithPort(armType, armId, selectedPort);
|
519 |
+
|
520 |
+
calibrationStatusDiv.innerHTML =
|
521 |
+
'<div class="success">β
Calibration completed successfully!</div>';
|
522 |
+
startCalibrationBtn.textContent = "Start Calibration";
|
523 |
+
} catch (error) {
|
524 |
+
calibrationStatusDiv.innerHTML = `
|
525 |
+
<div class="error">β Calibration failed: ${
|
526 |
+
error instanceof Error ? error.message : error
|
527 |
+
}</div>
|
528 |
+
`;
|
529 |
+
startCalibrationBtn.textContent = "Start Calibration";
|
530 |
+
} finally {
|
531 |
+
startCalibrationBtn.disabled = !connectedPorts.some(
|
532 |
+
(port) => port.isConnected
|
533 |
+
);
|
534 |
+
}
|
535 |
+
});
|
536 |
+
|
537 |
+
// Initialize
|
538 |
+
updateCalibrationButton();
|
539 |
+
|
540 |
+
// Initialize the application
|
541 |
+
initializeApp();
|
542 |
+
|
543 |
+
// Override console.log to show in the page
|
544 |
+
const logDiv = document.querySelector<HTMLDivElement>("#log")!;
|
545 |
+
console.log = (...args) => {
|
546 |
+
originalConsoleLog.apply(console, args);
|
547 |
+
|
548 |
+
const logEntry = document.createElement("div");
|
549 |
+
logEntry.className = "log-entry";
|
550 |
+
logEntry.textContent = args.join(" ");
|
551 |
+
logDiv.appendChild(logEntry);
|
552 |
+
logDiv.scrollTop = logDiv.scrollHeight;
|
553 |
+
};
|