Spaces:
Running
Running
feat: calibration in web
Browse files- docs/conventions.md +201 -9
- package.json +2 -0
- pnpm-lock.yaml +459 -0
- src/demo/components/CalibrationModal.tsx +50 -0
- src/demo/components/CalibrationPanel.tsx +354 -274
- src/demo/components/CalibrationWizard.tsx +0 -217
- src/demo/components/PortManager.tsx +288 -51
- src/demo/components/ui/dialog.tsx +120 -0
- src/demo/components/ui/progress.tsx +26 -0
- src/demo/lib/unified-storage.ts +325 -0
- src/demo/main.tsx +1 -5
- src/demo/pages/Calibrate.tsx +0 -121
- src/demo/pages/Setup.tsx +0 -99
- src/lerobot/web/calibrate.ts +681 -253
- src/main.ts +0 -553
- vanilla.html +0 -32
docs/conventions.md
CHANGED
@@ -48,17 +48,35 @@
|
|
48 |
|
49 |
## Architecture Principles
|
50 |
|
51 |
-
### 1.
|
52 |
|
53 |
-
**
|
|
|
|
|
54 |
|
55 |
- **Identical Commands**: `npx lerobot find-port` matches `python -m lerobot.find_port`
|
56 |
- **Same Terminology**: Use "MotorsBus", not "robot arms" - keep Python's exact wording
|
57 |
- **Matching Output**: Error messages, prompts, and flow identical to Python version
|
58 |
- **Familiar Workflows**: Python lerobot users should feel immediately at home
|
59 |
-
- **
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
60 |
|
61 |
-
> **Why?**
|
|
|
|
|
|
|
|
|
|
|
|
|
62 |
|
63 |
### 2. Modular Design
|
64 |
|
@@ -123,17 +141,34 @@ lerobot/
|
|
123 |
|
124 |
### Implementation Philosophy
|
125 |
|
|
|
|
|
126 |
- **Python First**: When in doubt, check how Python lerobot does it
|
127 |
-
- **
|
128 |
-
- **User Expectations**: Maintain
|
129 |
- **Terminology Consistency**: Use Python lerobot's exact naming and messaging
|
130 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
131 |
### Development Process
|
132 |
|
|
|
|
|
133 |
- **Python Reference**: Always check Python lerobot implementation first
|
134 |
-
- **
|
135 |
- **User Story Validation**: Validate against real Python lerobot users
|
136 |
|
|
|
|
|
|
|
|
|
|
|
|
|
137 |
### Testing Strategy
|
138 |
|
139 |
- **Unit Tests**: Vitest for individual functions and classes
|
@@ -165,9 +200,166 @@ lerobot/
|
|
165 |
- **Hardware**: Platform-specific libraries for device access
|
166 |
- **Development**: Vitest, ESLint, Prettier
|
167 |
|
168 |
-
##
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
169 |
|
170 |
-
|
171 |
|
172 |
#### Baudrate Configuration
|
173 |
|
|
|
48 |
|
49 |
## Architecture Principles
|
50 |
|
51 |
+
### 1. Platform-Appropriate Design Philosophy
|
52 |
|
53 |
+
**Each platform should leverage its strengths while maintaining core robotics compatibility**
|
54 |
+
|
55 |
+
#### Node.js: Python lerobot Faithfulness
|
56 |
|
57 |
- **Identical Commands**: `npx lerobot find-port` matches `python -m lerobot.find_port`
|
58 |
- **Same Terminology**: Use "MotorsBus", not "robot arms" - keep Python's exact wording
|
59 |
- **Matching Output**: Error messages, prompts, and flow identical to Python version
|
60 |
- **Familiar Workflows**: Python lerobot users should feel immediately at home
|
61 |
+
- **CLI Compatibility**: Direct migration path from Python CLI
|
62 |
+
|
63 |
+
> **Why for Node.js?** CLI users are already trained on Python lerobot. Node.js provides seamless migration to TypeScript without learning new patterns.
|
64 |
+
|
65 |
+
#### Web: Modern Robotics UX
|
66 |
+
|
67 |
+
- **Superior User Experience**: Leverage browser capabilities for better robotics interfaces
|
68 |
+
- **Real-time Visual Feedback**: Live motor position displays, progress indicators, interactive calibration
|
69 |
+
- **Professional Web UI**: Modern component libraries, responsive design, accessibility
|
70 |
+
- **Browser-Native Patterns**: Use web standards like dialogs, forms, notifications appropriately
|
71 |
+
- **Enhanced Workflows**: Improve upon CLI limitations with graphical interfaces
|
72 |
|
73 |
+
> **Why for Web?** Web platforms can provide significantly better UX than CLI tools. Users expect modern, intuitive interfaces when using browser applications.
|
74 |
+
|
75 |
+
#### Shared Core: Robotics Protocol Compatibility
|
76 |
+
|
77 |
+
- **Identical Hardware Communication**: Same motor protocols, timing, calibration algorithms
|
78 |
+
- **Compatible Data Formats**: Calibration files work across all platforms
|
79 |
+
- **Consistent Robotics Logic**: Motor control, kinematics, safety systems identical
|
80 |
|
81 |
### 2. Modular Design
|
82 |
|
|
|
141 |
|
142 |
### Implementation Philosophy
|
143 |
|
144 |
+
#### Node.js Development
|
145 |
+
|
146 |
- **Python First**: When in doubt, check how Python lerobot does it
|
147 |
+
- **Direct Ports**: Mirror Python implementation for CLI compatibility
|
148 |
+
- **User Expectations**: Maintain exact experience Python CLI users expect
|
149 |
- **Terminology Consistency**: Use Python lerobot's exact naming and messaging
|
150 |
|
151 |
+
#### Web Development
|
152 |
+
|
153 |
+
- **Hardware Logic First**: Reuse Node.js's proven robotics protocols and algorithms
|
154 |
+
- **UX Innovation**: Improve upon CLI limitations with modern web interfaces
|
155 |
+
- **User Expectations**: Provide intuitive, visual experiences that exceed CLI capabilities
|
156 |
+
- **Web Standards**: Follow browser conventions and accessibility guidelines
|
157 |
+
|
158 |
### Development Process
|
159 |
|
160 |
+
#### Node.js Process
|
161 |
+
|
162 |
- **Python Reference**: Always check Python lerobot implementation first
|
163 |
+
- **CLI Matching**: Test that commands, outputs, and workflows match exactly
|
164 |
- **User Story Validation**: Validate against real Python lerobot users
|
165 |
|
166 |
+
#### Web Process
|
167 |
+
|
168 |
+
- **Hardware Foundation**: Start with Node.js robotics logic as proven base
|
169 |
+
- **UX Enhancement**: Design interfaces that provide better experience than CLI
|
170 |
+
- **User Testing**: Validate with both robotics experts and general web users
|
171 |
+
|
172 |
### Testing Strategy
|
173 |
|
174 |
- **Unit Tests**: Vitest for individual functions and classes
|
|
|
200 |
- **Hardware**: Platform-specific libraries for device access
|
201 |
- **Development**: Vitest, ESLint, Prettier
|
202 |
|
203 |
+
## Platform-Specific Implementation
|
204 |
+
|
205 |
+
### Node.js Implementation (Python-Compatible Foundation)
|
206 |
+
|
207 |
+
**Node.js serves as our Python-compatible foundation - closest to original lerobot behavior**
|
208 |
+
|
209 |
+
#### Core Principles for Node.js
|
210 |
+
|
211 |
+
- **Direct Python Ports**: Mirror Python lerobot APIs and workflows exactly
|
212 |
+
- **System-Level Access**: Leverage Node.js's full system capabilities
|
213 |
+
- **Performance Priority**: Direct hardware access without browser security constraints
|
214 |
+
- **CLI Compatibility**: Commands should feel identical to Python lerobot CLI
|
215 |
+
|
216 |
+
#### Node.js Hardware Stack
|
217 |
+
|
218 |
+
- **Serial Communication**: `serialport` package for direct hardware access
|
219 |
+
- **Data Types**: Node.js Buffer API for binary communication
|
220 |
+
- **File System**: Direct fs access for calibration files and datasets
|
221 |
+
- **Port Discovery**: Programmatic port enumeration without user dialogs
|
222 |
+
- **Process Management**: Direct process control and system integration
|
223 |
+
|
224 |
+
### Web Implementation (Modern Robotics Interface)
|
225 |
+
|
226 |
+
**Web provides superior robotics UX by building on Node.js's proven hardware protocols**
|
227 |
+
|
228 |
+
#### Core Principles for Web
|
229 |
+
|
230 |
+
- **Hardware Protocol Reuse**: Leverage Node.js's proven motor communication and calibration algorithms
|
231 |
+
- **Superior User Experience**: Create intuitive, visual interfaces that surpass CLI limitations
|
232 |
+
- **Browser-Native Design**: Use modern web patterns, components, and interactions appropriately
|
233 |
+
- **Real-time Capabilities**: Provide live feedback and interactive control impossible in CLI
|
234 |
+
- **Professional Quality**: Match or exceed commercial robotics software interfaces
|
235 |
+
|
236 |
+
#### Critical Web-Specific Adaptations
|
237 |
+
|
238 |
+
##### 1. Serial Communication Adaptation
|
239 |
+
|
240 |
+
- **Foundation**: Reuse Node.js Feetech protocol timing and packet structures
|
241 |
+
- **API Translation**:
|
242 |
+
|
243 |
+
```typescript
|
244 |
+
// Node.js (serialport)
|
245 |
+
port.on("data", callback);
|
246 |
+
|
247 |
+
// Web (Web Serial API)
|
248 |
+
const reader = port.readable.getReader();
|
249 |
+
const { value } = await reader.read();
|
250 |
+
```
|
251 |
+
|
252 |
+
- **Browser Constraints**: Promise-based instead of event-based, user permission required
|
253 |
+
- **Timing Differences**: 10ms write-to-read delays, different buffer management
|
254 |
+
|
255 |
+
##### 2. Data Type Adaptation
|
256 |
+
|
257 |
+
- **Node.js**: `Buffer` API for binary data
|
258 |
+
- **Web**: `Uint8Array` for browser compatibility
|
259 |
+
- **Translation Pattern**:
|
260 |
+
|
261 |
+
```typescript
|
262 |
+
// Node.js
|
263 |
+
const packet = Buffer.from([0xff, 0xff, motorId]);
|
264 |
+
|
265 |
+
// Web
|
266 |
+
const packet = new Uint8Array([0xff, 0xff, motorId]);
|
267 |
+
```
|
268 |
+
|
269 |
+
##### 3. Storage Strategy Adaptation
|
270 |
+
|
271 |
+
- **Node.js**: Direct file system access (`fs.writeFileSync`)
|
272 |
+
- **Web**: Browser storage APIs (`localStorage`, `IndexedDB`)
|
273 |
+
- **Device Persistence**:
|
274 |
+
- **Node.js**: File-based device configs
|
275 |
+
- **Web**: Hardware serial numbers + `WebUSB.getDevices()` for auto-restoration
|
276 |
+
|
277 |
+
##### 4. Device Discovery Adaptation
|
278 |
+
|
279 |
+
- **Node.js**: Programmatic port listing (`SerialPort.list()`)
|
280 |
+
- **Web**: User-initiated port selection (`navigator.serial.requestPort()`)
|
281 |
+
- **Auto-Reconnection**:
|
282 |
+
- **Node.js**: Automatic based on saved port paths
|
283 |
+
- **Web**: WebUSB device matching + Web Serial port restoration
|
284 |
+
|
285 |
+
##### 5. UI Framework Integration
|
286 |
+
|
287 |
+
- **Node.js**: CLI-based interaction (inquirer, chalk)
|
288 |
+
- **Web**: React components with real-time hardware data binding
|
289 |
+
- **Critical Challenges Solved**:
|
290 |
+
- **React.StrictMode**: Disabled for hardware interfaces (`src/demo/main.tsx`)
|
291 |
+
- **Concurrent Access**: Single controlled serial operation via custom hooks
|
292 |
+
- **Real-time Updates**: Hardware callbacks → React state updates
|
293 |
+
- **Professional UI**: shadcn Dialog, Card, Button components for robotics interfaces
|
294 |
+
- **Architecture Pattern**:
|
295 |
+
```typescript
|
296 |
+
// Custom hook for hardware state management
|
297 |
+
function useCalibration(robot: ConnectedRobot) {
|
298 |
+
const [controller, setController] =
|
299 |
+
useState<WebCalibrationController | null>(null);
|
300 |
+
// Stable dependencies to prevent infinite re-renders
|
301 |
+
const startCalibration = useCallback(async () => {
|
302 |
+
/* ... */
|
303 |
+
}, [dependencies]);
|
304 |
+
}
|
305 |
+
```
|
306 |
+
|
307 |
+
#### Web Implementation Blockers Solved
|
308 |
+
|
309 |
+
**These blockers were identified during SO-100 web calibration development:**
|
310 |
+
|
311 |
+
1. **Web Serial Communication Protocol**
|
312 |
+
|
313 |
+
- **Issue**: Browser timing differs from Node.js serialport
|
314 |
+
- **Solution**: Adapt Node.js Feetech patterns with Promise.race timeouts
|
315 |
+
- **Pattern**: Reuse protocol logic, translate API calls
|
316 |
+
|
317 |
+
2. **React + Hardware Integration**
|
318 |
+
|
319 |
+
- **Issue**: React lifecycle conflicts with hardware state
|
320 |
+
- **Solution**: Controlled serial access, proper useCallback dependencies
|
321 |
+
- **Pattern**: Hardware operations outside React render cycle
|
322 |
+
|
323 |
+
3. **Real-Time Hardware Display**
|
324 |
+
|
325 |
+
- **Issue**: UI showing calculated values instead of live positions
|
326 |
+
- **Solution**: Hardware callbacks pass current positions to React
|
327 |
+
- **Pattern**: Hardware → callback → React state → UI update
|
328 |
+
|
329 |
+
4. **Browser Storage for Hardware**
|
330 |
+
|
331 |
+
- **Issue**: Multiple localStorage keys causing state inconsistency (e.g., `lerobot-robot-{serial}`, `lerobot-calibration-{serial}`, `lerobot_calibration_{type}_{id}`)
|
332 |
+
- **Solution**: Unified storage system with automatic migration from old formats
|
333 |
+
- **Implementation**:
|
334 |
+
|
335 |
+
```typescript
|
336 |
+
// Unified key format
|
337 |
+
const key = `lerobotjs-${serialNumber}`
|
338 |
+
|
339 |
+
// Unified data structure
|
340 |
+
{
|
341 |
+
device_info: { serialNumber, robotType, robotId, usbMetadata },
|
342 |
+
calibration: { motor_data..., metadata: { timestamp, readCount } }
|
343 |
+
}
|
344 |
+
```
|
345 |
+
|
346 |
+
- **Auto-Migration**: Automatically consolidates scattered old keys into unified format
|
347 |
+
- **Pattern**: Single source of truth per physical device
|
348 |
+
|
349 |
+
5. **Device Persistence Across Sessions**
|
350 |
+
|
351 |
+
- **Issue**: Serial numbers lost on page reload
|
352 |
+
- **Solution**: WebUSB `getDevices()` + automatic device restoration
|
353 |
+
- **Pattern**: Hardware ID persistence without user re-permission
|
354 |
+
|
355 |
+
6. **Professional Hardware UI**
|
356 |
+
- **Issue**: Browser alerts inappropriate for robotics interfaces
|
357 |
+
- **Solution**: shadcn Dialog components with device information
|
358 |
+
- **Pattern**: Professional component library for hardware control
|
359 |
+
|
360 |
+
### Hardware Implementation Lessons (Universal Patterns)
|
361 |
|
362 |
+
#### Critical Hardware Compatibility (Both Platforms)
|
363 |
|
364 |
#### Baudrate Configuration
|
365 |
|
package.json
CHANGED
@@ -33,6 +33,8 @@
|
|
33 |
"install-global": "pnpm run build && npm link"
|
34 |
},
|
35 |
"dependencies": {
|
|
|
|
|
36 |
"log-update": "^6.1.0",
|
37 |
"serialport": "^12.0.0"
|
38 |
},
|
|
|
33 |
"install-global": "pnpm run build && npm link"
|
34 |
},
|
35 |
"dependencies": {
|
36 |
+
"@radix-ui/react-dialog": "^1.1.14",
|
37 |
+
"@radix-ui/react-progress": "^1.1.7",
|
38 |
"log-update": "^6.1.0",
|
39 |
"serialport": "^12.0.0"
|
40 |
},
|
pnpm-lock.yaml
CHANGED
@@ -8,6 +8,12 @@ importers:
|
|
8 |
|
9 |
.:
|
10 |
dependencies:
|
|
|
|
|
|
|
|
|
|
|
|
|
11 |
log-update:
|
12 |
specifier: ^6.1.0
|
13 |
version: 6.1.0
|
@@ -341,6 +347,190 @@ packages:
|
|
341 |
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
342 |
engines: {node: '>=14'}
|
343 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
344 |
'@rolldown/[email protected]':
|
345 |
resolution: {integrity: sha512-L/gAA/hyCSuzTF1ftlzUSI/IKr2POHsv1Dd78GfqkR83KMNuswWD61JxGV2L7nRwBBBSDr6R1gCkdTmoN7W4ag==}
|
346 |
|
@@ -573,6 +763,10 @@ packages:
|
|
573 | |
574 |
resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
|
575 |
|
|
|
|
|
|
|
|
|
576 | |
577 |
resolution: {integrity: sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==}
|
578 |
engines: {node: ^10 || ^12 || >=14}
|
@@ -656,6 +850,9 @@ packages:
|
|
656 |
supports-color:
|
657 |
optional: true
|
658 |
|
|
|
|
|
|
|
659 | |
660 |
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
|
661 |
|
@@ -732,6 +929,10 @@ packages:
|
|
732 |
resolution: {integrity: sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==}
|
733 |
engines: {node: '>=18'}
|
734 |
|
|
|
|
|
|
|
|
|
735 | |
736 |
resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==}
|
737 |
|
@@ -979,6 +1180,36 @@ packages:
|
|
979 |
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
|
980 |
engines: {node: '>=0.10.0'}
|
981 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
982 | |
983 |
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
|
984 |
engines: {node: '>=0.10.0'}
|
@@ -1100,6 +1331,9 @@ packages:
|
|
1100 | |
1101 |
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
|
1102 |
|
|
|
|
|
|
|
1103 | |
1104 |
resolution: {integrity: sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==}
|
1105 |
engines: {node: '>=18.0.0'}
|
@@ -1119,6 +1353,26 @@ packages:
|
|
1119 |
peerDependencies:
|
1120 |
browserslist: '>= 4.21.0'
|
1121 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1122 | |
1123 |
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
1124 |
|
@@ -1422,6 +1676,159 @@ snapshots:
|
|
1422 |
'@pkgjs/[email protected]':
|
1423 |
optional: true
|
1424 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1425 |
'@rolldown/[email protected]': {}
|
1426 |
|
1427 |
'@rollup/[email protected]':
|
@@ -1611,6 +2018,10 @@ snapshots:
|
|
1611 |
|
1612 | |
1613 |
|
|
|
|
|
|
|
|
|
1614 | |
1615 |
dependencies:
|
1616 |
browserslist: 4.25.0
|
@@ -1690,6 +2101,8 @@ snapshots:
|
|
1690 |
dependencies:
|
1691 |
ms: 2.1.2
|
1692 |
|
|
|
|
|
1693 | |
1694 |
|
1695 | |
@@ -1772,6 +2185,8 @@ snapshots:
|
|
1772 |
|
1773 | |
1774 |
|
|
|
|
|
1775 | |
1776 |
dependencies:
|
1777 |
resolve-pkg-maps: 1.0.0
|
@@ -1974,6 +2389,33 @@ snapshots:
|
|
1974 |
|
1975 | |
1976 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1977 | |
1978 |
dependencies:
|
1979 |
loose-envify: 1.4.0
|
@@ -2157,6 +2599,8 @@ snapshots:
|
|
2157 |
|
2158 | |
2159 |
|
|
|
|
|
2160 | |
2161 |
dependencies:
|
2162 |
esbuild: 0.25.5
|
@@ -2174,6 +2618,21 @@ snapshots:
|
|
2174 |
escalade: 3.2.0
|
2175 |
picocolors: 1.1.1
|
2176 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2177 | |
2178 |
|
2179 |
|
|
8 |
|
9 |
.:
|
10 |
dependencies:
|
11 |
+
'@radix-ui/react-dialog':
|
12 |
+
specifier: ^1.1.14
|
13 |
+
version: 1.1.14(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
14 |
+
'@radix-ui/react-progress':
|
15 |
+
specifier: ^1.1.7
|
16 |
+
version: 1.1.7(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
17 |
log-update:
|
18 |
specifier: ^6.1.0
|
19 |
version: 6.1.0
|
|
|
347 |
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
348 |
engines: {node: '>=14'}
|
349 |
|
350 |
+
'@radix-ui/[email protected]':
|
351 |
+
resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==}
|
352 |
+
|
353 |
+
'@radix-ui/[email protected]':
|
354 |
+
resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
|
355 |
+
peerDependencies:
|
356 |
+
'@types/react': '*'
|
357 |
+
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
358 |
+
peerDependenciesMeta:
|
359 |
+
'@types/react':
|
360 |
+
optional: true
|
361 |
+
|
362 |
+
'@radix-ui/[email protected]':
|
363 |
+
resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==}
|
364 |
+
peerDependencies:
|
365 |
+
'@types/react': '*'
|
366 |
+
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
367 |
+
peerDependenciesMeta:
|
368 |
+
'@types/react':
|
369 |
+
optional: true
|
370 |
+
|
371 |
+
'@radix-ui/[email protected]':
|
372 |
+
resolution: {integrity: sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==}
|
373 |
+
peerDependencies:
|
374 |
+
'@types/react': '*'
|
375 |
+
'@types/react-dom': '*'
|
376 |
+
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
377 |
+
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
378 |
+
peerDependenciesMeta:
|
379 |
+
'@types/react':
|
380 |
+
optional: true
|
381 |
+
'@types/react-dom':
|
382 |
+
optional: true
|
383 |
+
|
384 |
+
'@radix-ui/[email protected]':
|
385 |
+
resolution: {integrity: sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==}
|
386 |
+
peerDependencies:
|
387 |
+
'@types/react': '*'
|
388 |
+
'@types/react-dom': '*'
|
389 |
+
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
390 |
+
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
391 |
+
peerDependenciesMeta:
|
392 |
+
'@types/react':
|
393 |
+
optional: true
|
394 |
+
'@types/react-dom':
|
395 |
+
optional: true
|
396 |
+
|
397 |
+
'@radix-ui/[email protected]':
|
398 |
+
resolution: {integrity: sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==}
|
399 |
+
peerDependencies:
|
400 |
+
'@types/react': '*'
|
401 |
+
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
402 |
+
peerDependenciesMeta:
|
403 |
+
'@types/react':
|
404 |
+
optional: true
|
405 |
+
|
406 |
+
'@radix-ui/[email protected]':
|
407 |
+
resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==}
|
408 |
+
peerDependencies:
|
409 |
+
'@types/react': '*'
|
410 |
+
'@types/react-dom': '*'
|
411 |
+
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
412 |
+
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
413 |
+
peerDependenciesMeta:
|
414 |
+
'@types/react':
|
415 |
+
optional: true
|
416 |
+
'@types/react-dom':
|
417 |
+
optional: true
|
418 |
+
|
419 |
+
'@radix-ui/[email protected]':
|
420 |
+
resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
|
421 |
+
peerDependencies:
|
422 |
+
'@types/react': '*'
|
423 |
+
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
424 |
+
peerDependenciesMeta:
|
425 |
+
'@types/react':
|
426 |
+
optional: true
|
427 |
+
|
428 |
+
'@radix-ui/[email protected]':
|
429 |
+
resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==}
|
430 |
+
peerDependencies:
|
431 |
+
'@types/react': '*'
|
432 |
+
'@types/react-dom': '*'
|
433 |
+
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
434 |
+
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
435 |
+
peerDependenciesMeta:
|
436 |
+
'@types/react':
|
437 |
+
optional: true
|
438 |
+
'@types/react-dom':
|
439 |
+
optional: true
|
440 |
+
|
441 |
+
'@radix-ui/[email protected]':
|
442 |
+
resolution: {integrity: sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==}
|
443 |
+
peerDependencies:
|
444 |
+
'@types/react': '*'
|
445 |
+
'@types/react-dom': '*'
|
446 |
+
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
447 |
+
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
448 |
+
peerDependenciesMeta:
|
449 |
+
'@types/react':
|
450 |
+
optional: true
|
451 |
+
'@types/react-dom':
|
452 |
+
optional: true
|
453 |
+
|
454 |
+
'@radix-ui/[email protected]':
|
455 |
+
resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==}
|
456 |
+
peerDependencies:
|
457 |
+
'@types/react': '*'
|
458 |
+
'@types/react-dom': '*'
|
459 |
+
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
460 |
+
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
461 |
+
peerDependenciesMeta:
|
462 |
+
'@types/react':
|
463 |
+
optional: true
|
464 |
+
'@types/react-dom':
|
465 |
+
optional: true
|
466 |
+
|
467 |
+
'@radix-ui/[email protected]':
|
468 |
+
resolution: {integrity: sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==}
|
469 |
+
peerDependencies:
|
470 |
+
'@types/react': '*'
|
471 |
+
'@types/react-dom': '*'
|
472 |
+
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
473 |
+
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
474 |
+
peerDependenciesMeta:
|
475 |
+
'@types/react':
|
476 |
+
optional: true
|
477 |
+
'@types/react-dom':
|
478 |
+
optional: true
|
479 |
+
|
480 |
+
'@radix-ui/[email protected]':
|
481 |
+
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
|
482 |
+
peerDependencies:
|
483 |
+
'@types/react': '*'
|
484 |
+
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
485 |
+
peerDependenciesMeta:
|
486 |
+
'@types/react':
|
487 |
+
optional: true
|
488 |
+
|
489 |
+
'@radix-ui/[email protected]':
|
490 |
+
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
|
491 |
+
peerDependencies:
|
492 |
+
'@types/react': '*'
|
493 |
+
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
494 |
+
peerDependenciesMeta:
|
495 |
+
'@types/react':
|
496 |
+
optional: true
|
497 |
+
|
498 |
+
'@radix-ui/[email protected]':
|
499 |
+
resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==}
|
500 |
+
peerDependencies:
|
501 |
+
'@types/react': '*'
|
502 |
+
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
503 |
+
peerDependenciesMeta:
|
504 |
+
'@types/react':
|
505 |
+
optional: true
|
506 |
+
|
507 |
+
'@radix-ui/[email protected]':
|
508 |
+
resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==}
|
509 |
+
peerDependencies:
|
510 |
+
'@types/react': '*'
|
511 |
+
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
512 |
+
peerDependenciesMeta:
|
513 |
+
'@types/react':
|
514 |
+
optional: true
|
515 |
+
|
516 |
+
'@radix-ui/[email protected]':
|
517 |
+
resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==}
|
518 |
+
peerDependencies:
|
519 |
+
'@types/react': '*'
|
520 |
+
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
521 |
+
peerDependenciesMeta:
|
522 |
+
'@types/react':
|
523 |
+
optional: true
|
524 |
+
|
525 |
+
'@radix-ui/[email protected]':
|
526 |
+
resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==}
|
527 |
+
peerDependencies:
|
528 |
+
'@types/react': '*'
|
529 |
+
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
530 |
+
peerDependenciesMeta:
|
531 |
+
'@types/react':
|
532 |
+
optional: true
|
533 |
+
|
534 |
'@rolldown/[email protected]':
|
535 |
resolution: {integrity: sha512-L/gAA/hyCSuzTF1ftlzUSI/IKr2POHsv1Dd78GfqkR83KMNuswWD61JxGV2L7nRwBBBSDr6R1gCkdTmoN7W4ag==}
|
536 |
|
|
|
763 | |
764 |
resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
|
765 |
|
766 | |
767 |
+
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
768 |
+
engines: {node: '>=10'}
|
769 |
+
|
770 | |
771 |
resolution: {integrity: sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==}
|
772 |
engines: {node: ^10 || ^12 || >=14}
|
|
|
850 |
supports-color:
|
851 |
optional: true
|
852 |
|
853 | |
854 |
+
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
|
855 |
+
|
856 | |
857 |
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
|
858 |
|
|
|
929 |
resolution: {integrity: sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==}
|
930 |
engines: {node: '>=18'}
|
931 |
|
932 | |
933 |
+
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
|
934 |
+
engines: {node: '>=6'}
|
935 |
+
|
936 | |
937 |
resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==}
|
938 |
|
|
|
1180 |
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
|
1181 |
engines: {node: '>=0.10.0'}
|
1182 |
|
1183 | |
1184 |
+
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
|
1185 |
+
engines: {node: '>=10'}
|
1186 |
+
peerDependencies:
|
1187 |
+
'@types/react': '*'
|
1188 |
+
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
1189 |
+
peerDependenciesMeta:
|
1190 |
+
'@types/react':
|
1191 |
+
optional: true
|
1192 |
+
|
1193 | |
1194 |
+
resolution: {integrity: sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==}
|
1195 |
+
engines: {node: '>=10'}
|
1196 |
+
peerDependencies:
|
1197 |
+
'@types/react': '*'
|
1198 |
+
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
1199 |
+
peerDependenciesMeta:
|
1200 |
+
'@types/react':
|
1201 |
+
optional: true
|
1202 |
+
|
1203 | |
1204 |
+
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
|
1205 |
+
engines: {node: '>=10'}
|
1206 |
+
peerDependencies:
|
1207 |
+
'@types/react': '*'
|
1208 |
+
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
1209 |
+
peerDependenciesMeta:
|
1210 |
+
'@types/react':
|
1211 |
+
optional: true
|
1212 |
+
|
1213 | |
1214 |
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
|
1215 |
engines: {node: '>=0.10.0'}
|
|
|
1331 | |
1332 |
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
|
1333 |
|
1334 | |
1335 |
+
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
1336 |
+
|
1337 | |
1338 |
resolution: {integrity: sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==}
|
1339 |
engines: {node: '>=18.0.0'}
|
|
|
1353 |
peerDependencies:
|
1354 |
browserslist: '>= 4.21.0'
|
1355 |
|
1356 | |
1357 |
+
resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==}
|
1358 |
+
engines: {node: '>=10'}
|
1359 |
+
peerDependencies:
|
1360 |
+
'@types/react': '*'
|
1361 |
+
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
1362 |
+
peerDependenciesMeta:
|
1363 |
+
'@types/react':
|
1364 |
+
optional: true
|
1365 |
+
|
1366 | |
1367 |
+
resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==}
|
1368 |
+
engines: {node: '>=10'}
|
1369 |
+
peerDependencies:
|
1370 |
+
'@types/react': '*'
|
1371 |
+
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
1372 |
+
peerDependenciesMeta:
|
1373 |
+
'@types/react':
|
1374 |
+
optional: true
|
1375 |
+
|
1376 | |
1377 |
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
1378 |
|
|
|
1676 |
'@pkgjs/[email protected]':
|
1677 |
optional: true
|
1678 |
|
1679 |
+
'@radix-ui/[email protected]': {}
|
1680 |
+
|
1681 |
+
'@radix-ui/[email protected](@types/[email protected])([email protected])':
|
1682 |
+
dependencies:
|
1683 |
+
react: 18.3.1
|
1684 |
+
optionalDependencies:
|
1685 |
+
'@types/react': 18.3.23
|
1686 |
+
|
1687 |
+
'@radix-ui/[email protected](@types/[email protected])([email protected])':
|
1688 |
+
dependencies:
|
1689 |
+
react: 18.3.1
|
1690 |
+
optionalDependencies:
|
1691 |
+
'@types/react': 18.3.23
|
1692 |
+
|
1693 |
+
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
|
1694 |
+
dependencies:
|
1695 |
+
'@radix-ui/primitive': 1.1.2
|
1696 |
+
'@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected])
|
1697 |
+
'@radix-ui/react-context': 1.1.2(@types/[email protected])([email protected])
|
1698 |
+
'@radix-ui/react-dismissable-layer': 1.1.10(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
1699 |
+
'@radix-ui/react-focus-guards': 1.1.2(@types/[email protected])([email protected])
|
1700 |
+
'@radix-ui/react-focus-scope': 1.1.7(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
1701 |
+
'@radix-ui/react-id': 1.1.1(@types/[email protected])([email protected])
|
1702 |
+
'@radix-ui/react-portal': 1.1.9(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
1703 |
+
'@radix-ui/react-presence': 1.1.4(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
1704 |
+
'@radix-ui/react-primitive': 2.1.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
1705 |
+
'@radix-ui/react-slot': 1.2.3(@types/[email protected])([email protected])
|
1706 |
+
'@radix-ui/react-use-controllable-state': 1.2.2(@types/[email protected])([email protected])
|
1707 |
+
aria-hidden: 1.2.6
|
1708 |
+
react: 18.3.1
|
1709 |
+
react-dom: 18.3.1([email protected])
|
1710 |
+
react-remove-scroll: 2.7.1(@types/[email protected])([email protected])
|
1711 |
+
optionalDependencies:
|
1712 |
+
'@types/react': 18.3.23
|
1713 |
+
'@types/react-dom': 18.3.7(@types/[email protected])
|
1714 |
+
|
1715 |
+
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
|
1716 |
+
dependencies:
|
1717 |
+
'@radix-ui/primitive': 1.1.2
|
1718 |
+
'@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected])
|
1719 |
+
'@radix-ui/react-primitive': 2.1.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
1720 |
+
'@radix-ui/react-use-callback-ref': 1.1.1(@types/[email protected])([email protected])
|
1721 |
+
'@radix-ui/react-use-escape-keydown': 1.1.1(@types/[email protected])([email protected])
|
1722 |
+
react: 18.3.1
|
1723 |
+
react-dom: 18.3.1([email protected])
|
1724 |
+
optionalDependencies:
|
1725 |
+
'@types/react': 18.3.23
|
1726 |
+
'@types/react-dom': 18.3.7(@types/[email protected])
|
1727 |
+
|
1728 |
+
'@radix-ui/[email protected](@types/[email protected])([email protected])':
|
1729 |
+
dependencies:
|
1730 |
+
react: 18.3.1
|
1731 |
+
optionalDependencies:
|
1732 |
+
'@types/react': 18.3.23
|
1733 |
+
|
1734 |
+
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
|
1735 |
+
dependencies:
|
1736 |
+
'@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected])
|
1737 |
+
'@radix-ui/react-primitive': 2.1.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
1738 |
+
'@radix-ui/react-use-callback-ref': 1.1.1(@types/[email protected])([email protected])
|
1739 |
+
react: 18.3.1
|
1740 |
+
react-dom: 18.3.1([email protected])
|
1741 |
+
optionalDependencies:
|
1742 |
+
'@types/react': 18.3.23
|
1743 |
+
'@types/react-dom': 18.3.7(@types/[email protected])
|
1744 |
+
|
1745 |
+
'@radix-ui/[email protected](@types/[email protected])([email protected])':
|
1746 |
+
dependencies:
|
1747 |
+
'@radix-ui/react-use-layout-effect': 1.1.1(@types/[email protected])([email protected])
|
1748 |
+
react: 18.3.1
|
1749 |
+
optionalDependencies:
|
1750 |
+
'@types/react': 18.3.23
|
1751 |
+
|
1752 |
+
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
|
1753 |
+
dependencies:
|
1754 |
+
'@radix-ui/react-primitive': 2.1.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
1755 |
+
'@radix-ui/react-use-layout-effect': 1.1.1(@types/[email protected])([email protected])
|
1756 |
+
react: 18.3.1
|
1757 |
+
react-dom: 18.3.1([email protected])
|
1758 |
+
optionalDependencies:
|
1759 |
+
'@types/react': 18.3.23
|
1760 |
+
'@types/react-dom': 18.3.7(@types/[email protected])
|
1761 |
+
|
1762 |
+
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
|
1763 |
+
dependencies:
|
1764 |
+
'@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected])
|
1765 |
+
'@radix-ui/react-use-layout-effect': 1.1.1(@types/[email protected])([email protected])
|
1766 |
+
react: 18.3.1
|
1767 |
+
react-dom: 18.3.1([email protected])
|
1768 |
+
optionalDependencies:
|
1769 |
+
'@types/react': 18.3.23
|
1770 |
+
'@types/react-dom': 18.3.7(@types/[email protected])
|
1771 |
+
|
1772 |
+
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
|
1773 |
+
dependencies:
|
1774 |
+
'@radix-ui/react-slot': 1.2.3(@types/[email protected])([email protected])
|
1775 |
+
react: 18.3.1
|
1776 |
+
react-dom: 18.3.1([email protected])
|
1777 |
+
optionalDependencies:
|
1778 |
+
'@types/react': 18.3.23
|
1779 |
+
'@types/react-dom': 18.3.7(@types/[email protected])
|
1780 |
+
|
1781 |
+
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
|
1782 |
+
dependencies:
|
1783 |
+
'@radix-ui/react-context': 1.1.2(@types/[email protected])([email protected])
|
1784 |
+
'@radix-ui/react-primitive': 2.1.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
1785 |
+
react: 18.3.1
|
1786 |
+
react-dom: 18.3.1([email protected])
|
1787 |
+
optionalDependencies:
|
1788 |
+
'@types/react': 18.3.23
|
1789 |
+
'@types/react-dom': 18.3.7(@types/[email protected])
|
1790 |
+
|
1791 |
+
'@radix-ui/[email protected](@types/[email protected])([email protected])':
|
1792 |
+
dependencies:
|
1793 |
+
'@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected])
|
1794 |
+
react: 18.3.1
|
1795 |
+
optionalDependencies:
|
1796 |
+
'@types/react': 18.3.23
|
1797 |
+
|
1798 |
+
'@radix-ui/[email protected](@types/[email protected])([email protected])':
|
1799 |
+
dependencies:
|
1800 |
+
react: 18.3.1
|
1801 |
+
optionalDependencies:
|
1802 |
+
'@types/react': 18.3.23
|
1803 |
+
|
1804 |
+
'@radix-ui/[email protected](@types/[email protected])([email protected])':
|
1805 |
+
dependencies:
|
1806 |
+
'@radix-ui/react-use-effect-event': 0.0.2(@types/[email protected])([email protected])
|
1807 |
+
'@radix-ui/react-use-layout-effect': 1.1.1(@types/[email protected])([email protected])
|
1808 |
+
react: 18.3.1
|
1809 |
+
optionalDependencies:
|
1810 |
+
'@types/react': 18.3.23
|
1811 |
+
|
1812 |
+
'@radix-ui/[email protected](@types/[email protected])([email protected])':
|
1813 |
+
dependencies:
|
1814 |
+
'@radix-ui/react-use-layout-effect': 1.1.1(@types/[email protected])([email protected])
|
1815 |
+
react: 18.3.1
|
1816 |
+
optionalDependencies:
|
1817 |
+
'@types/react': 18.3.23
|
1818 |
+
|
1819 |
+
'@radix-ui/[email protected](@types/[email protected])([email protected])':
|
1820 |
+
dependencies:
|
1821 |
+
'@radix-ui/react-use-callback-ref': 1.1.1(@types/[email protected])([email protected])
|
1822 |
+
react: 18.3.1
|
1823 |
+
optionalDependencies:
|
1824 |
+
'@types/react': 18.3.23
|
1825 |
+
|
1826 |
+
'@radix-ui/[email protected](@types/[email protected])([email protected])':
|
1827 |
+
dependencies:
|
1828 |
+
react: 18.3.1
|
1829 |
+
optionalDependencies:
|
1830 |
+
'@types/react': 18.3.23
|
1831 |
+
|
1832 |
'@rolldown/[email protected]': {}
|
1833 |
|
1834 |
'@rollup/[email protected]':
|
|
|
2018 |
|
2019 | |
2020 |
|
2021 | |
2022 |
+
dependencies:
|
2023 |
+
tslib: 2.8.1
|
2024 |
+
|
2025 | |
2026 |
dependencies:
|
2027 |
browserslist: 4.25.0
|
|
|
2101 |
dependencies:
|
2102 |
ms: 2.1.2
|
2103 |
|
2104 |
+
[email protected]: {}
|
2105 |
+
|
2106 | |
2107 |
|
2108 | |
|
|
2185 |
|
2186 | |
2187 |
|
2188 |
+
[email protected]: {}
|
2189 |
+
|
2190 | |
2191 |
dependencies:
|
2192 |
resolve-pkg-maps: 1.0.0
|
|
|
2389 |
|
2390 | |
2391 |
|
2392 |
+
[email protected](@types/[email protected])([email protected]):
|
2393 |
+
dependencies:
|
2394 |
+
react: 18.3.1
|
2395 |
+
react-style-singleton: 2.2.3(@types/[email protected])([email protected])
|
2396 |
+
tslib: 2.8.1
|
2397 |
+
optionalDependencies:
|
2398 |
+
'@types/react': 18.3.23
|
2399 |
+
|
2400 |
+
[email protected](@types/[email protected])([email protected]):
|
2401 |
+
dependencies:
|
2402 |
+
react: 18.3.1
|
2403 |
+
react-remove-scroll-bar: 2.3.8(@types/[email protected])([email protected])
|
2404 |
+
react-style-singleton: 2.2.3(@types/[email protected])([email protected])
|
2405 |
+
tslib: 2.8.1
|
2406 |
+
use-callback-ref: 1.3.3(@types/[email protected])([email protected])
|
2407 |
+
use-sidecar: 1.1.3(@types/[email protected])([email protected])
|
2408 |
+
optionalDependencies:
|
2409 |
+
'@types/react': 18.3.23
|
2410 |
+
|
2411 |
+
[email protected](@types/[email protected])([email protected]):
|
2412 |
+
dependencies:
|
2413 |
+
get-nonce: 1.0.1
|
2414 |
+
react: 18.3.1
|
2415 |
+
tslib: 2.8.1
|
2416 |
+
optionalDependencies:
|
2417 |
+
'@types/react': 18.3.23
|
2418 |
+
|
2419 | |
2420 |
dependencies:
|
2421 |
loose-envify: 1.4.0
|
|
|
2599 |
|
2600 | |
2601 |
|
2602 |
+
[email protected]: {}
|
2603 |
+
|
2604 | |
2605 |
dependencies:
|
2606 |
esbuild: 0.25.5
|
|
|
2618 |
escalade: 3.2.0
|
2619 |
picocolors: 1.1.1
|
2620 |
|
2621 |
+
[email protected](@types/[email protected])([email protected]):
|
2622 |
+
dependencies:
|
2623 |
+
react: 18.3.1
|
2624 |
+
tslib: 2.8.1
|
2625 |
+
optionalDependencies:
|
2626 |
+
'@types/react': 18.3.23
|
2627 |
+
|
2628 |
+
[email protected](@types/[email protected])([email protected]):
|
2629 |
+
dependencies:
|
2630 |
+
detect-node-es: 1.1.0
|
2631 |
+
react: 18.3.1
|
2632 |
+
tslib: 2.8.1
|
2633 |
+
optionalDependencies:
|
2634 |
+
'@types/react': 18.3.23
|
2635 |
+
|
2636 | |
2637 |
|
2638 |
src/demo/components/CalibrationModal.tsx
ADDED
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React from "react";
|
2 |
+
import {
|
3 |
+
Dialog,
|
4 |
+
DialogContent,
|
5 |
+
DialogDescription,
|
6 |
+
DialogFooter,
|
7 |
+
DialogHeader,
|
8 |
+
DialogTitle,
|
9 |
+
} from "./ui/dialog";
|
10 |
+
import { Button } from "./ui/button";
|
11 |
+
|
12 |
+
interface CalibrationModalProps {
|
13 |
+
open: boolean;
|
14 |
+
onOpenChange: (open: boolean) => void;
|
15 |
+
deviceType: string;
|
16 |
+
onContinue: () => void;
|
17 |
+
}
|
18 |
+
|
19 |
+
export function CalibrationModal({
|
20 |
+
open,
|
21 |
+
onOpenChange,
|
22 |
+
deviceType,
|
23 |
+
onContinue,
|
24 |
+
}: CalibrationModalProps) {
|
25 |
+
return (
|
26 |
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
27 |
+
<DialogContent className="sm:max-w-md">
|
28 |
+
<DialogHeader>
|
29 |
+
<DialogTitle>📍 Set Homing Position</DialogTitle>
|
30 |
+
<DialogDescription className="text-base py-4">
|
31 |
+
Move the SO-100 {deviceType} to the <strong>MIDDLE</strong> of its
|
32 |
+
range of motion and click OK when ready.
|
33 |
+
<br />
|
34 |
+
<br />
|
35 |
+
The calibration will then automatically:
|
36 |
+
<br />• Record homing offsets
|
37 |
+
<br />• Record joint ranges (manual - you control when to stop)
|
38 |
+
<br />• Save configuration file
|
39 |
+
</DialogDescription>
|
40 |
+
</DialogHeader>
|
41 |
+
|
42 |
+
<DialogFooter>
|
43 |
+
<Button onClick={onContinue} className="w-full">
|
44 |
+
OK - Start Calibration
|
45 |
+
</Button>
|
46 |
+
</DialogFooter>
|
47 |
+
</DialogContent>
|
48 |
+
</Dialog>
|
49 |
+
);
|
50 |
+
}
|
src/demo/components/CalibrationPanel.tsx
CHANGED
@@ -1,4 +1,10 @@
|
|
1 |
-
import React, {
|
|
|
|
|
|
|
|
|
|
|
|
|
2 |
import { Button } from "./ui/button";
|
3 |
import {
|
4 |
Card,
|
@@ -8,7 +14,13 @@ import {
|
|
8 |
CardTitle,
|
9 |
} from "./ui/card";
|
10 |
import { Badge } from "./ui/badge";
|
11 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
12 |
import type { ConnectedRobot } from "../types";
|
13 |
|
14 |
interface CalibrationPanelProps {
|
@@ -24,275 +36,367 @@ interface MotorCalibrationData {
|
|
24 |
range: number;
|
25 |
}
|
26 |
|
27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
28 |
const [isCalibrating, setIsCalibrating] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
29 |
const [motorData, setMotorData] = useState<MotorCalibrationData[]>([]);
|
30 |
-
const [calibrationStatus, setCalibrationStatus] =
|
31 |
-
useState<string>("Ready to calibrate");
|
32 |
-
const [calibrationComplete, setCalibrationComplete] = useState(false);
|
33 |
-
const [readCount, setReadCount] = useState(0);
|
34 |
|
35 |
-
|
36 |
-
const
|
37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
38 |
|
39 |
-
//
|
40 |
-
const
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
|
|
|
|
|
|
|
|
48 |
|
49 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
50 |
const initializeMotorData = useCallback(() => {
|
51 |
const initialData = motorNames.map((name) => ({
|
52 |
name,
|
53 |
-
current: 2047,
|
54 |
min: 2047,
|
55 |
max: 2047,
|
56 |
range: 0,
|
57 |
}));
|
58 |
setMotorData(initialData);
|
59 |
-
|
60 |
-
}, []);
|
61 |
|
62 |
-
//
|
63 |
-
const
|
64 |
-
|
65 |
-
|
|
|
66 |
|
67 |
-
|
68 |
-
|
69 |
-
|
70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
71 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
72 |
|
73 |
-
|
74 |
-
|
|
|
|
|
75 |
|
76 |
-
|
77 |
-
|
78 |
-
|
|
|
|
|
79 |
|
80 |
-
|
81 |
-
|
82 |
-
|
83 |
-
let success = false;
|
84 |
-
let retries = 2; // Allow 2 retries per motor
|
85 |
-
|
86 |
-
while (!success && retries > 0) {
|
87 |
-
try {
|
88 |
-
// Create STS3215 Read Position packet
|
89 |
-
const packet = new Uint8Array([
|
90 |
-
0xff,
|
91 |
-
0xff,
|
92 |
-
motorId,
|
93 |
-
0x04,
|
94 |
-
0x02,
|
95 |
-
0x38,
|
96 |
-
0x02,
|
97 |
-
0x00,
|
98 |
-
]);
|
99 |
-
const checksum = ~(motorId + 0x04 + 0x02 + 0x38 + 0x02) & 0xff;
|
100 |
-
packet[7] = checksum;
|
101 |
-
|
102 |
-
// Write packet
|
103 |
-
await writer.write(packet);
|
104 |
-
|
105 |
-
// Wait for response
|
106 |
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
107 |
-
|
108 |
-
// Read with timeout
|
109 |
-
const timeoutPromise = new Promise((_, reject) =>
|
110 |
-
setTimeout(() => reject(new Error("Timeout")), 100)
|
111 |
);
|
|
|
112 |
|
113 |
-
|
114 |
-
|
115 |
-
timeoutPromise,
|
116 |
-
])) as ReadableStreamReadResult<Uint8Array>;
|
117 |
-
|
118 |
-
if (
|
119 |
-
result &&
|
120 |
-
!result.done &&
|
121 |
-
result.value &&
|
122 |
-
result.value.length >= 7
|
123 |
-
) {
|
124 |
-
const response = result.value;
|
125 |
-
const responseId = response[2];
|
126 |
-
const error = response[4];
|
127 |
-
|
128 |
-
// Check if this is the response we're looking for
|
129 |
-
if (responseId === motorId && error === 0) {
|
130 |
-
const position = response[5] | (response[6] << 8);
|
131 |
-
positions.push(position);
|
132 |
-
lastKnownPositions.current[index] = position; // Update last known good position
|
133 |
-
success = true;
|
134 |
-
} else {
|
135 |
-
// Wrong motor ID or error - might be out of sync, try again
|
136 |
-
retries--;
|
137 |
-
await new Promise((resolve) => setTimeout(resolve, 5));
|
138 |
-
}
|
139 |
-
} else {
|
140 |
-
retries--;
|
141 |
-
await new Promise((resolve) => setTimeout(resolve, 5));
|
142 |
}
|
143 |
-
} catch (error) {
|
144 |
-
retries--;
|
145 |
-
await new Promise((resolve) => setTimeout(resolve, 5));
|
146 |
}
|
147 |
-
|
148 |
-
|
149 |
-
|
150 |
-
// Use last known good position instead of fallback center position
|
151 |
-
positions.push(lastKnownPositions.current[index]);
|
152 |
-
}
|
153 |
-
|
154 |
-
// Small delay between motors
|
155 |
-
await new Promise((resolve) => setTimeout(resolve, 2));
|
156 |
}
|
157 |
-
}
|
158 |
-
|
159 |
-
|
160 |
-
}
|
161 |
-
|
162 |
-
return positions;
|
163 |
-
}, [robot.port]);
|
164 |
-
|
165 |
-
// Update motor data with new readings - NO SIMULATION, REAL VALUES ONLY
|
166 |
-
const updateMotorData = useCallback(async () => {
|
167 |
-
if (!isCalibrating || isReading.current) return;
|
168 |
-
|
169 |
-
const now = performance.now();
|
170 |
-
// Read at ~15Hz to reduce serial communication load (66ms intervals)
|
171 |
-
if (now - lastReadTime.current < 66) return;
|
172 |
-
|
173 |
-
lastReadTime.current = now;
|
174 |
-
isReading.current = true;
|
175 |
|
176 |
-
|
177 |
-
|
178 |
-
|
179 |
-
|
180 |
-
|
181 |
-
|
182 |
-
|
183 |
-
|
184 |
-
|
185 |
}
|
186 |
|
187 |
-
|
188 |
-
|
189 |
-
|
190 |
-
|
191 |
-
|
192 |
-
|
193 |
-
|
194 |
-
|
195 |
-
|
196 |
-
|
197 |
-
|
198 |
-
|
199 |
-
|
200 |
-
|
201 |
-
|
202 |
-
|
203 |
-
|
204 |
-
|
205 |
-
|
206 |
-
|
207 |
-
|
208 |
-
|
209 |
-
|
210 |
-
|
211 |
-
}`
|
212 |
);
|
213 |
-
} finally {
|
214 |
-
isReading.current = false;
|
215 |
-
}
|
216 |
-
}, [isCalibrating, readMotorPositions]);
|
217 |
|
218 |
-
|
219 |
-
|
220 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
221 |
|
222 |
-
|
223 |
-
|
224 |
-
|
225 |
-
|
|
|
|
|
|
|
|
|
|
|
226 |
|
|
|
227 |
useEffect(() => {
|
228 |
initializeMotorData();
|
229 |
}, [initializeMotorData]);
|
230 |
|
231 |
-
|
232 |
-
|
233 |
-
|
234 |
-
|
235 |
-
|
236 |
-
|
237 |
-
|
238 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
239 |
|
240 |
-
|
241 |
-
|
242 |
-
|
243 |
-
|
244 |
-
|
245 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
246 |
|
247 |
-
const startCalibration = async () => {
|
248 |
if (!robot.port || !robot.robotType) {
|
249 |
-
setCalibrationStatus("Error: Invalid robot configuration");
|
250 |
return;
|
251 |
}
|
252 |
|
253 |
-
setCalibrationStatus(
|
254 |
-
"Initializing calibration - reading current positions..."
|
255 |
-
);
|
256 |
-
|
257 |
try {
|
258 |
-
|
259 |
-
|
260 |
-
|
261 |
-
|
262 |
-
|
263 |
-
|
264 |
-
|
265 |
-
|
266 |
-
|
267 |
-
|
268 |
-
|
269 |
-
|
270 |
-
|
271 |
-
|
272 |
-
|
273 |
-
|
274 |
-
|
275 |
-
|
276 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
277 |
} catch (error) {
|
278 |
-
|
279 |
-
`Error starting calibration: ${
|
280 |
-
error instanceof Error ? error.message : error
|
281 |
-
}`
|
282 |
-
);
|
283 |
}
|
284 |
};
|
285 |
|
286 |
-
// Generate calibration config JSON matching
|
287 |
const generateConfigJSON = () => {
|
288 |
-
|
289 |
-
|
290 |
-
|
291 |
-
|
292 |
-
|
293 |
-
|
294 |
-
|
295 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
296 |
|
297 |
return calibrationData;
|
298 |
};
|
@@ -313,47 +417,6 @@ export function CalibrationPanel({ robot, onFinish }: CalibrationPanelProps) {
|
|
313 |
URL.revokeObjectURL(url);
|
314 |
};
|
315 |
|
316 |
-
const finishCalibration = () => {
|
317 |
-
setIsCalibrating(false);
|
318 |
-
setCalibrationComplete(true);
|
319 |
-
setCalibrationStatus(
|
320 |
-
`✅ Calibration completed! Recorded ${readCount} position readings.`
|
321 |
-
);
|
322 |
-
|
323 |
-
// Save calibration config to localStorage using serial number
|
324 |
-
const configData = generateConfigJSON();
|
325 |
-
const serialNumber = (robot as any).serialNumber;
|
326 |
-
|
327 |
-
if (!serialNumber) {
|
328 |
-
console.warn("⚠️ No serial number available for calibration storage");
|
329 |
-
setCalibrationStatus(
|
330 |
-
`⚠️ Calibration completed but cannot save - no robot serial number`
|
331 |
-
);
|
332 |
-
return;
|
333 |
-
}
|
334 |
-
|
335 |
-
const calibrationKey = `lerobot-calibration-${serialNumber}`;
|
336 |
-
try {
|
337 |
-
localStorage.setItem(
|
338 |
-
calibrationKey,
|
339 |
-
JSON.stringify({
|
340 |
-
config: configData,
|
341 |
-
timestamp: new Date().toISOString(),
|
342 |
-
serialNumber: serialNumber,
|
343 |
-
robotId: robot.robotId,
|
344 |
-
robotType: robot.robotType,
|
345 |
-
readCount: readCount,
|
346 |
-
})
|
347 |
-
);
|
348 |
-
console.log(`💾 Calibration saved for robot serial: ${serialNumber}`);
|
349 |
-
} catch (error) {
|
350 |
-
console.warn("Failed to save calibration to localStorage:", error);
|
351 |
-
setCalibrationStatus(
|
352 |
-
`⚠️ Calibration completed but save failed: ${error}`
|
353 |
-
);
|
354 |
-
}
|
355 |
-
};
|
356 |
-
|
357 |
return (
|
358 |
<div className="space-y-4">
|
359 |
{/* Calibration Status Card */}
|
@@ -372,14 +435,14 @@ export function CalibrationPanel({ robot, onFinish }: CalibrationPanelProps) {
|
|
372 |
variant={
|
373 |
isCalibrating
|
374 |
? "default"
|
375 |
-
:
|
376 |
? "default"
|
377 |
: "outline"
|
378 |
}
|
379 |
>
|
380 |
{isCalibrating
|
381 |
? "Recording"
|
382 |
-
:
|
383 |
? "Complete"
|
384 |
: "Ready"}
|
385 |
</Badge>
|
@@ -389,26 +452,35 @@ export function CalibrationPanel({ robot, onFinish }: CalibrationPanelProps) {
|
|
389 |
<div className="space-y-4">
|
390 |
<div className="p-3 bg-blue-50 rounded-lg">
|
391 |
<p className="text-sm font-medium text-blue-900">Status:</p>
|
392 |
-
<p className="text-sm text-blue-800">{
|
393 |
{isCalibrating && (
|
394 |
<p className="text-xs text-blue-600 mt-1">
|
395 |
-
|
|
|
396 |
</p>
|
397 |
)}
|
398 |
</div>
|
399 |
|
400 |
<div className="flex gap-2">
|
401 |
-
{!isCalibrating && !
|
402 |
-
<Button onClick={
|
|
|
|
|
403 |
)}
|
404 |
|
405 |
-
{isCalibrating && (
|
406 |
<Button onClick={finishCalibration} variant="outline">
|
407 |
Finish Calibration
|
408 |
</Button>
|
409 |
)}
|
410 |
|
411 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
412 |
<>
|
413 |
<Button onClick={downloadConfigJSON} variant="outline">
|
414 |
Download Config JSON
|
@@ -422,7 +494,7 @@ export function CalibrationPanel({ robot, onFinish }: CalibrationPanelProps) {
|
|
422 |
</Card>
|
423 |
|
424 |
{/* Configuration JSON Display */}
|
425 |
-
{
|
426 |
<Card>
|
427 |
<CardHeader>
|
428 |
<CardTitle className="text-lg">
|
@@ -521,6 +593,14 @@ export function CalibrationPanel({ robot, onFinish }: CalibrationPanelProps) {
|
|
521 |
)}
|
522 |
</CardContent>
|
523 |
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
524 |
</div>
|
525 |
);
|
526 |
}
|
|
|
1 |
+
import React, {
|
2 |
+
useState,
|
3 |
+
useEffect,
|
4 |
+
useCallback,
|
5 |
+
useRef,
|
6 |
+
useMemo,
|
7 |
+
} from "react";
|
8 |
import { Button } from "./ui/button";
|
9 |
import {
|
10 |
Card,
|
|
|
14 |
CardTitle,
|
15 |
} from "./ui/card";
|
16 |
import { Badge } from "./ui/badge";
|
17 |
+
import {
|
18 |
+
createCalibrationController,
|
19 |
+
WebCalibrationController,
|
20 |
+
saveCalibrationResults,
|
21 |
+
type WebCalibrationResults,
|
22 |
+
} from "../../lerobot/web/calibrate";
|
23 |
+
import { CalibrationModal } from "./CalibrationModal";
|
24 |
import type { ConnectedRobot } from "../types";
|
25 |
|
26 |
interface CalibrationPanelProps {
|
|
|
36 |
range: number;
|
37 |
}
|
38 |
|
39 |
+
/**
|
40 |
+
* Custom hook for calibration that manages the serial port properly
|
41 |
+
* Uses vanilla calibration functions internally, provides React-friendly interface
|
42 |
+
*/
|
43 |
+
function useCalibration(robot: ConnectedRobot) {
|
44 |
+
const [controller, setController] = useState<WebCalibrationController | null>(
|
45 |
+
null
|
46 |
+
);
|
47 |
const [isCalibrating, setIsCalibrating] = useState(false);
|
48 |
+
const [isRecordingRanges, setIsRecordingRanges] = useState(false);
|
49 |
+
const [calibrationResult, setCalibrationResult] =
|
50 |
+
useState<WebCalibrationResults | null>(null);
|
51 |
+
const [status, setStatus] = useState<string>("Ready to calibrate");
|
52 |
+
|
53 |
+
// Motor data state
|
54 |
const [motorData, setMotorData] = useState<MotorCalibrationData[]>([]);
|
|
|
|
|
|
|
|
|
55 |
|
56 |
+
// Static motor names - use useMemo to prevent recreation on every render
|
57 |
+
const motorNames = useMemo(
|
58 |
+
() => [
|
59 |
+
"shoulder_pan",
|
60 |
+
"shoulder_lift",
|
61 |
+
"elbow_flex",
|
62 |
+
"wrist_flex",
|
63 |
+
"wrist_roll",
|
64 |
+
"gripper",
|
65 |
+
],
|
66 |
+
[]
|
67 |
+
);
|
68 |
|
69 |
+
// Initialize controller when robot changes
|
70 |
+
const initializeController = useCallback(async () => {
|
71 |
+
if (!robot.port || !robot.robotType) {
|
72 |
+
throw new Error("Invalid robot configuration");
|
73 |
+
}
|
74 |
+
|
75 |
+
const newController = await createCalibrationController(
|
76 |
+
robot.robotType,
|
77 |
+
robot.port
|
78 |
+
);
|
79 |
+
setController(newController);
|
80 |
+
return newController;
|
81 |
+
}, [robot.port, robot.robotType]);
|
82 |
|
83 |
+
// Read motor positions using the controller (no concurrent access)
|
84 |
+
const readMotorPositions = useCallback(async (): Promise<number[]> => {
|
85 |
+
if (!controller) {
|
86 |
+
throw new Error("Controller not initialized");
|
87 |
+
}
|
88 |
+
return await controller.readMotorPositions();
|
89 |
+
}, [controller]);
|
90 |
+
|
91 |
+
// Update motor data from positions
|
92 |
+
const updateMotorData = useCallback(
|
93 |
+
(
|
94 |
+
positions: number[],
|
95 |
+
rangeMins?: { [motor: string]: number },
|
96 |
+
rangeMaxes?: { [motor: string]: number }
|
97 |
+
) => {
|
98 |
+
const newData = motorNames.map((name, index) => {
|
99 |
+
const current = positions[index];
|
100 |
+
const min = rangeMins ? rangeMins[name] : current;
|
101 |
+
const max = rangeMaxes ? rangeMaxes[name] : current;
|
102 |
+
|
103 |
+
return {
|
104 |
+
name,
|
105 |
+
current,
|
106 |
+
min,
|
107 |
+
max,
|
108 |
+
range: max - min,
|
109 |
+
};
|
110 |
+
});
|
111 |
+
|
112 |
+
setMotorData(newData);
|
113 |
+
},
|
114 |
+
[motorNames]
|
115 |
+
);
|
116 |
+
|
117 |
+
// Initialize motor data
|
118 |
const initializeMotorData = useCallback(() => {
|
119 |
const initialData = motorNames.map((name) => ({
|
120 |
name,
|
121 |
+
current: 2047,
|
122 |
min: 2047,
|
123 |
max: 2047,
|
124 |
range: 0,
|
125 |
}));
|
126 |
setMotorData(initialData);
|
127 |
+
}, [motorNames]);
|
|
|
128 |
|
129 |
+
// Start calibration process
|
130 |
+
const startCalibration = useCallback(async () => {
|
131 |
+
try {
|
132 |
+
setStatus("🤖 Starting calibration process...");
|
133 |
+
setIsCalibrating(true);
|
134 |
|
135 |
+
const ctrl = await initializeController();
|
136 |
+
|
137 |
+
// Step 1: Homing
|
138 |
+
setStatus("📍 Setting homing position...");
|
139 |
+
await ctrl.performHomingStep();
|
140 |
+
|
141 |
+
return ctrl;
|
142 |
+
} catch (error) {
|
143 |
+
setIsCalibrating(false);
|
144 |
+
throw error;
|
145 |
}
|
146 |
+
}, [initializeController]);
|
147 |
+
|
148 |
+
// Start range recording
|
149 |
+
const startRangeRecording = useCallback(
|
150 |
+
async (
|
151 |
+
controllerToUse: WebCalibrationController,
|
152 |
+
stopFunction: () => boolean,
|
153 |
+
onUpdate?: (
|
154 |
+
mins: { [motor: string]: number },
|
155 |
+
maxes: { [motor: string]: number },
|
156 |
+
currentPositions: { [motor: string]: number }
|
157 |
+
) => void
|
158 |
+
) => {
|
159 |
+
if (!controllerToUse) {
|
160 |
+
throw new Error("Controller not provided");
|
161 |
+
}
|
162 |
|
163 |
+
setStatus(
|
164 |
+
"📏 Recording joint ranges - move all joints through their full range"
|
165 |
+
);
|
166 |
+
setIsRecordingRanges(true);
|
167 |
|
168 |
+
try {
|
169 |
+
await controllerToUse.performRangeRecordingStep(
|
170 |
+
stopFunction,
|
171 |
+
(rangeMins, rangeMaxes, currentPositions) => {
|
172 |
+
setStatus("📏 Recording joint ranges - capturing data...");
|
173 |
|
174 |
+
// Update motor data with CURRENT LIVE POSITIONS (not averages!)
|
175 |
+
const currentPositionsArray = motorNames.map(
|
176 |
+
(name) => currentPositions[name]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
177 |
);
|
178 |
+
updateMotorData(currentPositionsArray, rangeMins, rangeMaxes);
|
179 |
|
180 |
+
if (onUpdate) {
|
181 |
+
onUpdate(rangeMins, rangeMaxes, currentPositions);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
182 |
}
|
|
|
|
|
|
|
183 |
}
|
184 |
+
);
|
185 |
+
} finally {
|
186 |
+
setIsRecordingRanges(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
187 |
}
|
188 |
+
},
|
189 |
+
[motorNames, updateMotorData]
|
190 |
+
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
191 |
|
192 |
+
// Finish calibration
|
193 |
+
const finishCalibration = useCallback(
|
194 |
+
async (
|
195 |
+
controllerToUse?: WebCalibrationController,
|
196 |
+
recordingCount?: number
|
197 |
+
) => {
|
198 |
+
const ctrl = controllerToUse || controller;
|
199 |
+
if (!ctrl) {
|
200 |
+
throw new Error("Controller not initialized");
|
201 |
}
|
202 |
|
203 |
+
setStatus("💾 Finishing calibration...");
|
204 |
+
const result = await ctrl.finishCalibration();
|
205 |
+
setCalibrationResult(result);
|
206 |
+
|
207 |
+
// Save results using serial number for dashboard detection
|
208 |
+
// Use the same serial number logic as dashboard: prefer main serialNumber, fallback to USB metadata, then "unknown"
|
209 |
+
const serialNumber =
|
210 |
+
robot.serialNumber || robot.usbMetadata?.serialNumber || "unknown";
|
211 |
+
|
212 |
+
console.log("🔍 Debug - Saving calibration with:", {
|
213 |
+
robotType: robot.robotType,
|
214 |
+
robotId: robot.robotId,
|
215 |
+
mainSerialNumber: robot.serialNumber,
|
216 |
+
usbSerialNumber: robot.usbMetadata?.serialNumber,
|
217 |
+
finalSerialNumber: serialNumber,
|
218 |
+
storageKey: `lerobotjs-${serialNumber}`,
|
219 |
+
});
|
220 |
+
|
221 |
+
await saveCalibrationResults(
|
222 |
+
result,
|
223 |
+
robot.robotType!,
|
224 |
+
robot.robotId || `${robot.robotType}_1`,
|
225 |
+
serialNumber,
|
226 |
+
recordingCount || 0
|
|
|
227 |
);
|
|
|
|
|
|
|
|
|
228 |
|
229 |
+
// Update final motor data
|
230 |
+
const finalData = motorNames.map((motorName) => {
|
231 |
+
const motorResult = result[motorName];
|
232 |
+
return {
|
233 |
+
name: motorName,
|
234 |
+
current: (motorResult.range_min + motorResult.range_max) / 2,
|
235 |
+
min: motorResult.range_min,
|
236 |
+
max: motorResult.range_max,
|
237 |
+
range: motorResult.range_max - motorResult.range_min,
|
238 |
+
};
|
239 |
+
});
|
240 |
+
|
241 |
+
setMotorData(finalData);
|
242 |
+
setStatus("✅ Calibration completed successfully! Configuration saved.");
|
243 |
+
setIsCalibrating(false);
|
244 |
+
|
245 |
+
return result;
|
246 |
+
},
|
247 |
+
[controller, robot.robotType, robot.robotId, motorNames]
|
248 |
+
);
|
249 |
|
250 |
+
// Reset states
|
251 |
+
const reset = useCallback(() => {
|
252 |
+
setController(null);
|
253 |
+
setIsCalibrating(false);
|
254 |
+
setIsRecordingRanges(false);
|
255 |
+
setCalibrationResult(null);
|
256 |
+
setStatus("Ready to calibrate");
|
257 |
+
initializeMotorData();
|
258 |
+
}, [initializeMotorData]);
|
259 |
|
260 |
+
// Initialize on mount
|
261 |
useEffect(() => {
|
262 |
initializeMotorData();
|
263 |
}, [initializeMotorData]);
|
264 |
|
265 |
+
return {
|
266 |
+
// State
|
267 |
+
controller,
|
268 |
+
isCalibrating,
|
269 |
+
isRecordingRanges,
|
270 |
+
calibrationResult,
|
271 |
+
status,
|
272 |
+
motorData,
|
273 |
+
|
274 |
+
// Actions
|
275 |
+
startCalibration,
|
276 |
+
startRangeRecording,
|
277 |
+
finishCalibration,
|
278 |
+
readMotorPositions,
|
279 |
+
reset,
|
280 |
+
|
281 |
+
// Utilities
|
282 |
+
updateMotorData,
|
283 |
+
};
|
284 |
+
}
|
285 |
|
286 |
+
export function CalibrationPanel({ robot, onFinish }: CalibrationPanelProps) {
|
287 |
+
const {
|
288 |
+
controller,
|
289 |
+
isCalibrating,
|
290 |
+
isRecordingRanges,
|
291 |
+
calibrationResult,
|
292 |
+
status,
|
293 |
+
motorData,
|
294 |
+
startCalibration,
|
295 |
+
startRangeRecording,
|
296 |
+
finishCalibration,
|
297 |
+
readMotorPositions,
|
298 |
+
reset,
|
299 |
+
updateMotorData,
|
300 |
+
} = useCalibration(robot);
|
301 |
+
|
302 |
+
// Modal state
|
303 |
+
const [modalOpen, setModalOpen] = useState(false);
|
304 |
+
|
305 |
+
// Recording state
|
306 |
+
const [stopRecordingFunction, setStopRecordingFunction] = useState<
|
307 |
+
(() => void) | null
|
308 |
+
>(null);
|
309 |
+
|
310 |
+
// Motor names matching Python lerobot exactly (NOT Node CLI)
|
311 |
+
const motorNames = [
|
312 |
+
"shoulder_pan",
|
313 |
+
"shoulder_lift",
|
314 |
+
"elbow_flex",
|
315 |
+
"wrist_flex",
|
316 |
+
"wrist_roll",
|
317 |
+
"gripper",
|
318 |
+
];
|
319 |
+
|
320 |
+
// Motor IDs for SO-100 (STS3215 servos)
|
321 |
+
const motorIds = [1, 2, 3, 4, 5, 6];
|
322 |
+
|
323 |
+
// Keep track of last known good positions to avoid glitches
|
324 |
+
const lastKnownPositions = useRef<number[]>([
|
325 |
+
2047, 2047, 2047, 2047, 2047, 2047,
|
326 |
+
]);
|
327 |
+
|
328 |
+
// NO concurrent motor reading - let the calibration hook handle all serial operations
|
329 |
+
|
330 |
+
const handleContinueCalibration = async () => {
|
331 |
+
setModalOpen(false);
|
332 |
|
|
|
333 |
if (!robot.port || !robot.robotType) {
|
|
|
334 |
return;
|
335 |
}
|
336 |
|
|
|
|
|
|
|
|
|
337 |
try {
|
338 |
+
const ctrl = await startCalibration();
|
339 |
+
|
340 |
+
// Set up manual control - user decides when to stop
|
341 |
+
let shouldStopRecording = false;
|
342 |
+
let recordingCount = 0;
|
343 |
+
|
344 |
+
// Create stop function and store it in state for the button
|
345 |
+
const stopRecording = () => {
|
346 |
+
shouldStopRecording = true;
|
347 |
+
};
|
348 |
+
setStopRecordingFunction(() => stopRecording);
|
349 |
+
|
350 |
+
// Add Enter key listener
|
351 |
+
const handleKeyPress = (event: KeyboardEvent) => {
|
352 |
+
if (event.key === "Enter") {
|
353 |
+
shouldStopRecording = true;
|
354 |
+
}
|
355 |
+
};
|
356 |
+
|
357 |
+
document.addEventListener("keydown", handleKeyPress);
|
358 |
+
|
359 |
+
try {
|
360 |
+
await startRangeRecording(
|
361 |
+
ctrl,
|
362 |
+
() => {
|
363 |
+
return shouldStopRecording;
|
364 |
+
},
|
365 |
+
(rangeMins, rangeMaxes, currentPositions) => {
|
366 |
+
recordingCount++;
|
367 |
+
}
|
368 |
+
);
|
369 |
+
} finally {
|
370 |
+
document.removeEventListener("keydown", handleKeyPress);
|
371 |
+
setStopRecordingFunction(null);
|
372 |
+
}
|
373 |
+
|
374 |
+
// Step 3: Finish calibration with recording count
|
375 |
+
await finishCalibration(ctrl, recordingCount);
|
376 |
} catch (error) {
|
377 |
+
console.error("❌ Calibration failed:", error);
|
|
|
|
|
|
|
|
|
378 |
}
|
379 |
};
|
380 |
|
381 |
+
// Generate calibration config JSON matching Python lerobot format (OBJECT format, not arrays)
|
382 |
const generateConfigJSON = () => {
|
383 |
+
// Use the calibration result if available
|
384 |
+
if (calibrationResult) {
|
385 |
+
return calibrationResult;
|
386 |
+
}
|
387 |
+
|
388 |
+
// Fallback: generate from motor data (shouldn't happen with new flow)
|
389 |
+
const calibrationData: any = {};
|
390 |
+
motorNames.forEach((motorName, index) => {
|
391 |
+
const motor = motorData[index];
|
392 |
+
calibrationData[motorName] = {
|
393 |
+
homing_offset: motor.current - 2047, // Center offset for STS3215 (4095/2)
|
394 |
+
drive_mode: 0, // Python lerobot SO-100 uses drive_mode 0
|
395 |
+
start_pos: motor.min,
|
396 |
+
end_pos: motor.max,
|
397 |
+
calib_mode: "middle", // Python lerobot SO-100 standard
|
398 |
+
};
|
399 |
+
});
|
400 |
|
401 |
return calibrationData;
|
402 |
};
|
|
|
417 |
URL.revokeObjectURL(url);
|
418 |
};
|
419 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
420 |
return (
|
421 |
<div className="space-y-4">
|
422 |
{/* Calibration Status Card */}
|
|
|
435 |
variant={
|
436 |
isCalibrating
|
437 |
? "default"
|
438 |
+
: calibrationResult
|
439 |
? "default"
|
440 |
: "outline"
|
441 |
}
|
442 |
>
|
443 |
{isCalibrating
|
444 |
? "Recording"
|
445 |
+
: calibrationResult
|
446 |
? "Complete"
|
447 |
: "Ready"}
|
448 |
</Badge>
|
|
|
452 |
<div className="space-y-4">
|
453 |
<div className="p-3 bg-blue-50 rounded-lg">
|
454 |
<p className="text-sm font-medium text-blue-900">Status:</p>
|
455 |
+
<p className="text-sm text-blue-800">{status}</p>
|
456 |
{isCalibrating && (
|
457 |
<p className="text-xs text-blue-600 mt-1">
|
458 |
+
Move joints through full range | Press "Finish Recording" when
|
459 |
+
done
|
460 |
</p>
|
461 |
)}
|
462 |
</div>
|
463 |
|
464 |
<div className="flex gap-2">
|
465 |
+
{!isCalibrating && !calibrationResult && (
|
466 |
+
<Button onClick={() => setModalOpen(true)}>
|
467 |
+
Start Calibration
|
468 |
+
</Button>
|
469 |
)}
|
470 |
|
471 |
+
{isCalibrating && !isRecordingRanges && (
|
472 |
<Button onClick={finishCalibration} variant="outline">
|
473 |
Finish Calibration
|
474 |
</Button>
|
475 |
)}
|
476 |
|
477 |
+
{isRecordingRanges && stopRecordingFunction && (
|
478 |
+
<Button onClick={stopRecordingFunction} variant="default">
|
479 |
+
Finish Recording
|
480 |
+
</Button>
|
481 |
+
)}
|
482 |
+
|
483 |
+
{calibrationResult && (
|
484 |
<>
|
485 |
<Button onClick={downloadConfigJSON} variant="outline">
|
486 |
Download Config JSON
|
|
|
494 |
</Card>
|
495 |
|
496 |
{/* Configuration JSON Display */}
|
497 |
+
{calibrationResult && (
|
498 |
<Card>
|
499 |
<CardHeader>
|
500 |
<CardTitle className="text-lg">
|
|
|
593 |
)}
|
594 |
</CardContent>
|
595 |
</Card>
|
596 |
+
|
597 |
+
{/* Calibration Modal */}
|
598 |
+
<CalibrationModal
|
599 |
+
open={modalOpen}
|
600 |
+
onOpenChange={setModalOpen}
|
601 |
+
deviceType={robot.robotType || "robot"}
|
602 |
+
onContinue={handleContinueCalibration}
|
603 |
+
/>
|
604 |
</div>
|
605 |
);
|
606 |
}
|
src/demo/components/CalibrationWizard.tsx
DELETED
@@ -1,217 +0,0 @@
|
|
1 |
-
import React, { useState, useEffect } from "react";
|
2 |
-
import { Button } from "./ui/button";
|
3 |
-
import {
|
4 |
-
Card,
|
5 |
-
CardContent,
|
6 |
-
CardDescription,
|
7 |
-
CardHeader,
|
8 |
-
CardTitle,
|
9 |
-
} from "./ui/card";
|
10 |
-
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
11 |
-
import { Badge } from "./ui/badge";
|
12 |
-
import { calibrateWithPort } from "../../lerobot/web/calibrate";
|
13 |
-
import type { ConnectedRobot } from "../types";
|
14 |
-
|
15 |
-
interface CalibrationWizardProps {
|
16 |
-
robot: ConnectedRobot;
|
17 |
-
onComplete: () => void;
|
18 |
-
onCancel: () => void;
|
19 |
-
}
|
20 |
-
|
21 |
-
interface CalibrationStep {
|
22 |
-
id: string;
|
23 |
-
title: string;
|
24 |
-
description: string;
|
25 |
-
status: "pending" | "running" | "complete" | "error";
|
26 |
-
message?: string;
|
27 |
-
}
|
28 |
-
|
29 |
-
export function CalibrationWizard({
|
30 |
-
robot,
|
31 |
-
onComplete,
|
32 |
-
onCancel,
|
33 |
-
}: CalibrationWizardProps) {
|
34 |
-
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
35 |
-
const [steps, setSteps] = useState<CalibrationStep[]>([
|
36 |
-
{
|
37 |
-
id: "init",
|
38 |
-
title: "Initialize Robot",
|
39 |
-
description: "Connecting to robot and checking status",
|
40 |
-
status: "pending",
|
41 |
-
},
|
42 |
-
{
|
43 |
-
id: "calibrate",
|
44 |
-
title: "Calibrate Motors",
|
45 |
-
description: "Running calibration sequence",
|
46 |
-
status: "pending",
|
47 |
-
},
|
48 |
-
{
|
49 |
-
id: "verify",
|
50 |
-
title: "Verify Calibration",
|
51 |
-
description: "Testing calibrated positions",
|
52 |
-
status: "pending",
|
53 |
-
},
|
54 |
-
{
|
55 |
-
id: "complete",
|
56 |
-
title: "Complete",
|
57 |
-
description: "Calibration finished successfully",
|
58 |
-
status: "pending",
|
59 |
-
},
|
60 |
-
]);
|
61 |
-
|
62 |
-
const [isRunning, setIsRunning] = useState(false);
|
63 |
-
const [error, setError] = useState<string | null>(null);
|
64 |
-
|
65 |
-
useEffect(() => {
|
66 |
-
startCalibration();
|
67 |
-
}, []);
|
68 |
-
|
69 |
-
const updateStep = (
|
70 |
-
stepId: string,
|
71 |
-
status: CalibrationStep["status"],
|
72 |
-
message?: string
|
73 |
-
) => {
|
74 |
-
setSteps((prev) =>
|
75 |
-
prev.map((step) =>
|
76 |
-
step.id === stepId ? { ...step, status, message } : step
|
77 |
-
)
|
78 |
-
);
|
79 |
-
};
|
80 |
-
|
81 |
-
const startCalibration = async () => {
|
82 |
-
if (!robot.port || !robot.robotType) {
|
83 |
-
setError("Invalid robot configuration");
|
84 |
-
return;
|
85 |
-
}
|
86 |
-
|
87 |
-
setIsRunning(true);
|
88 |
-
setError(null);
|
89 |
-
|
90 |
-
try {
|
91 |
-
// Step 1: Initialize
|
92 |
-
setCurrentStepIndex(0);
|
93 |
-
updateStep("init", "running");
|
94 |
-
await new Promise((resolve) => setTimeout(resolve, 1000));
|
95 |
-
updateStep("init", "complete", "Robot initialized successfully");
|
96 |
-
|
97 |
-
// Step 2: Calibrate
|
98 |
-
setCurrentStepIndex(1);
|
99 |
-
updateStep("calibrate", "running");
|
100 |
-
|
101 |
-
try {
|
102 |
-
await calibrateWithPort(robot.port, robot.robotType);
|
103 |
-
updateStep("calibrate", "complete", "Motor calibration completed");
|
104 |
-
} catch (error) {
|
105 |
-
updateStep(
|
106 |
-
"calibrate",
|
107 |
-
"error",
|
108 |
-
error instanceof Error ? error.message : "Calibration failed"
|
109 |
-
);
|
110 |
-
throw error;
|
111 |
-
}
|
112 |
-
|
113 |
-
// Step 3: Verify
|
114 |
-
setCurrentStepIndex(2);
|
115 |
-
updateStep("verify", "running");
|
116 |
-
await new Promise((resolve) => setTimeout(resolve, 1500));
|
117 |
-
updateStep("verify", "complete", "Calibration verified");
|
118 |
-
|
119 |
-
// Step 4: Complete
|
120 |
-
setCurrentStepIndex(3);
|
121 |
-
updateStep("complete", "complete", "Robot is ready for use");
|
122 |
-
|
123 |
-
setTimeout(() => {
|
124 |
-
onComplete();
|
125 |
-
}, 2000);
|
126 |
-
} catch (error) {
|
127 |
-
setError(error instanceof Error ? error.message : "Calibration failed");
|
128 |
-
} finally {
|
129 |
-
setIsRunning(false);
|
130 |
-
}
|
131 |
-
};
|
132 |
-
|
133 |
-
const getStepIcon = (status: CalibrationStep["status"]) => {
|
134 |
-
switch (status) {
|
135 |
-
case "pending":
|
136 |
-
return "⏳";
|
137 |
-
case "running":
|
138 |
-
return "🔄";
|
139 |
-
case "complete":
|
140 |
-
return "✅";
|
141 |
-
case "error":
|
142 |
-
return "❌";
|
143 |
-
default:
|
144 |
-
return "⏳";
|
145 |
-
}
|
146 |
-
};
|
147 |
-
|
148 |
-
const getStepBadgeVariant = (status: CalibrationStep["status"]) => {
|
149 |
-
switch (status) {
|
150 |
-
case "pending":
|
151 |
-
return "secondary" as const;
|
152 |
-
case "running":
|
153 |
-
return "default" as const;
|
154 |
-
case "complete":
|
155 |
-
return "default" as const;
|
156 |
-
case "error":
|
157 |
-
return "destructive" as const;
|
158 |
-
default:
|
159 |
-
return "secondary" as const;
|
160 |
-
}
|
161 |
-
};
|
162 |
-
|
163 |
-
return (
|
164 |
-
<div className="space-y-6">
|
165 |
-
<div className="text-center">
|
166 |
-
<h3 className="text-lg font-semibold mb-2">Calibration in Progress</h3>
|
167 |
-
<p className="text-muted-foreground">
|
168 |
-
Calibrating {robot.robotId} ({robot.robotType?.replace("_", " ")})
|
169 |
-
</p>
|
170 |
-
</div>
|
171 |
-
|
172 |
-
{error && (
|
173 |
-
<Alert variant="destructive">
|
174 |
-
<AlertDescription>{error}</AlertDescription>
|
175 |
-
</Alert>
|
176 |
-
)}
|
177 |
-
|
178 |
-
<div className="space-y-4">
|
179 |
-
{steps.map((step, index) => (
|
180 |
-
<Card
|
181 |
-
key={step.id}
|
182 |
-
className={index === currentStepIndex ? "ring-2 ring-blue-500" : ""}
|
183 |
-
>
|
184 |
-
<CardHeader className="pb-3">
|
185 |
-
<div className="flex items-center justify-between">
|
186 |
-
<div className="flex items-center space-x-3">
|
187 |
-
<span className="text-2xl">{getStepIcon(step.status)}</span>
|
188 |
-
<div>
|
189 |
-
<CardTitle className="text-base">{step.title}</CardTitle>
|
190 |
-
<CardDescription className="text-sm">
|
191 |
-
{step.description}
|
192 |
-
</CardDescription>
|
193 |
-
</div>
|
194 |
-
</div>
|
195 |
-
<Badge variant={getStepBadgeVariant(step.status)}>
|
196 |
-
{step.status.charAt(0).toUpperCase() + step.status.slice(1)}
|
197 |
-
</Badge>
|
198 |
-
</div>
|
199 |
-
</CardHeader>
|
200 |
-
{step.message && (
|
201 |
-
<CardContent className="pt-0">
|
202 |
-
<p className="text-sm text-muted-foreground">{step.message}</p>
|
203 |
-
</CardContent>
|
204 |
-
)}
|
205 |
-
</Card>
|
206 |
-
))}
|
207 |
-
</div>
|
208 |
-
|
209 |
-
<div className="flex justify-center space-x-4">
|
210 |
-
<Button variant="outline" onClick={onCancel} disabled={isRunning}>
|
211 |
-
Cancel
|
212 |
-
</Button>
|
213 |
-
{error && <Button onClick={startCalibration}>Retry Calibration</Button>}
|
214 |
-
</div>
|
215 |
-
</div>
|
216 |
-
);
|
217 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/demo/components/PortManager.tsx
CHANGED
@@ -9,6 +9,14 @@ import {
|
|
9 |
} from "./ui/card";
|
10 |
import { Alert, AlertDescription } from "./ui/alert";
|
11 |
import { Badge } from "./ui/badge";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
12 |
import { isWebSerialSupported } from "../../lerobot/web/calibrate";
|
13 |
import type { ConnectedRobot } from "../types";
|
14 |
|
@@ -31,7 +39,17 @@ export function PortManager({
|
|
31 |
const [isFindingPorts, setIsFindingPorts] = useState(false);
|
32 |
const [findPortsLog, setFindPortsLog] = useState<string[]>([]);
|
33 |
const [error, setError] = useState<string | null>(null);
|
34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
35 |
// Load saved port data from localStorage on mount
|
36 |
useEffect(() => {
|
37 |
loadSavedPorts();
|
@@ -73,22 +91,84 @@ export function PortManager({
|
|
73 |
// Check if already open
|
74 |
if (port.readable !== null && port.writable !== null) {
|
75 |
isConnected = true;
|
|
|
76 |
} else {
|
77 |
-
// Auto-open paired robots
|
78 |
-
|
79 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
80 |
}
|
81 |
} catch (error) {
|
82 |
console.log("Could not auto-connect to paired robot:", error);
|
83 |
isConnected = false;
|
84 |
}
|
85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
86 |
restoredPorts.push({
|
87 |
port,
|
88 |
name: getPortDisplayName(port),
|
89 |
isConnected,
|
90 |
robotType: savedPort?.robotType,
|
91 |
robotId: savedPort?.robotId,
|
|
|
|
|
92 |
});
|
93 |
}
|
94 |
|
@@ -223,18 +303,21 @@ export function PortManager({
|
|
223 |
usbMetadata: usbMetadata || undefined,
|
224 |
};
|
225 |
|
226 |
-
// Try to load saved robot info by serial number
|
227 |
-
|
228 |
-
|
229 |
-
|
230 |
-
|
231 |
-
|
232 |
-
|
233 |
-
|
234 |
-
|
|
|
|
|
|
|
|
|
|
|
235 |
}
|
236 |
-
} catch (error) {
|
237 |
-
console.warn("Failed to load saved robot data:", error);
|
238 |
}
|
239 |
|
240 |
onConnectedRobotsChange([...connectedRobots, newRobot]);
|
@@ -269,21 +352,109 @@ export function PortManager({
|
|
269 |
};
|
270 |
|
271 |
const handleDisconnect = async (index: number) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
272 |
try {
|
273 |
-
|
274 |
if (portInfo.isConnected) {
|
275 |
await portInfo.port.close();
|
276 |
}
|
277 |
|
278 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
279 |
onConnectedRobotsChange(updatedRobots);
|
|
|
|
|
|
|
|
|
280 |
} catch (error) {
|
281 |
setError(
|
282 |
-
error instanceof Error ? error.message : "Failed to
|
283 |
);
|
284 |
}
|
285 |
};
|
286 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
287 |
const handleUpdatePortInfo = (
|
288 |
index: number,
|
289 |
robotType: "so100_follower" | "so100_leader",
|
@@ -293,24 +464,24 @@ export function PortManager({
|
|
293 |
if (i === index) {
|
294 |
const updatedRobot = { ...robot, robotType, robotId };
|
295 |
|
296 |
-
// Save robot configuration
|
297 |
if (updatedRobot.serialNumber) {
|
298 |
-
|
299 |
-
|
300 |
-
|
301 |
-
|
302 |
-
|
303 |
-
|
304 |
-
|
305 |
-
|
306 |
-
|
307 |
-
|
308 |
-
|
309 |
-
|
310 |
-
)
|
311 |
-
|
312 |
-
|
313 |
-
|
314 |
}
|
315 |
|
316 |
return updatedRobot;
|
@@ -519,6 +690,61 @@ export function PortManager({
|
|
519 |
</div>
|
520 |
</div>
|
521 |
</CardContent>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
522 |
</Card>
|
523 |
);
|
524 |
}
|
@@ -549,22 +775,38 @@ function PortCard({
|
|
549 |
const [portMetadata, setPortMetadata] = useState<any>(null);
|
550 |
const [showDeviceInfo, setShowDeviceInfo] = useState(false);
|
551 |
|
552 |
-
// Check for calibration
|
553 |
const getCalibrationStatus = () => {
|
554 |
-
|
|
|
|
|
555 |
|
556 |
-
const calibrationKey = `lerobot-calibration-${portInfo.serialNumber}`;
|
557 |
try {
|
558 |
-
|
559 |
-
|
560 |
-
|
561 |
-
|
562 |
-
|
563 |
-
|
564 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
565 |
}
|
566 |
} catch (error) {
|
567 |
-
console.warn("Failed to read calibration from
|
568 |
}
|
569 |
return null;
|
570 |
};
|
@@ -816,11 +1058,6 @@ function PortCard({
|
|
816 |
<Badge variant={portInfo.isConnected ? "default" : "outline"}>
|
817 |
{portInfo.isConnected ? "Connected" : "Available"}
|
818 |
</Badge>
|
819 |
-
{portInfo.usbMetadata && (
|
820 |
-
<Badge variant="outline" className="text-xs">
|
821 |
-
{portInfo.usbMetadata.manufacturerName}
|
822 |
-
</Badge>
|
823 |
-
)}
|
824 |
</div>
|
825 |
<Button variant="destructive" size="sm" onClick={onDisconnect}>
|
826 |
Remove
|
|
|
9 |
} from "./ui/card";
|
10 |
import { Alert, AlertDescription } from "./ui/alert";
|
11 |
import { Badge } from "./ui/badge";
|
12 |
+
import {
|
13 |
+
Dialog,
|
14 |
+
DialogContent,
|
15 |
+
DialogDescription,
|
16 |
+
DialogFooter,
|
17 |
+
DialogHeader,
|
18 |
+
DialogTitle,
|
19 |
+
} from "./ui/dialog";
|
20 |
import { isWebSerialSupported } from "../../lerobot/web/calibrate";
|
21 |
import type { ConnectedRobot } from "../types";
|
22 |
|
|
|
39 |
const [isFindingPorts, setIsFindingPorts] = useState(false);
|
40 |
const [findPortsLog, setFindPortsLog] = useState<string[]>([]);
|
41 |
const [error, setError] = useState<string | null>(null);
|
42 |
+
const [confirmDeleteDialog, setConfirmDeleteDialog] = useState<{
|
43 |
+
open: boolean;
|
44 |
+
robotIndex: number;
|
45 |
+
robotName: string;
|
46 |
+
serialNumber: string;
|
47 |
+
}>({
|
48 |
+
open: false,
|
49 |
+
robotIndex: -1,
|
50 |
+
robotName: "",
|
51 |
+
serialNumber: "",
|
52 |
+
});
|
53 |
// Load saved port data from localStorage on mount
|
54 |
useEffect(() => {
|
55 |
loadSavedPorts();
|
|
|
91 |
// Check if already open
|
92 |
if (port.readable !== null && port.writable !== null) {
|
93 |
isConnected = true;
|
94 |
+
console.log("Port already open, reusing connection");
|
95 |
} else {
|
96 |
+
// Auto-open paired robots only if they have saved configuration
|
97 |
+
if (savedPort?.robotType && savedPort?.robotId) {
|
98 |
+
console.log(
|
99 |
+
`Auto-connecting to saved robot: ${savedPort.robotType} (${savedPort.robotId})`
|
100 |
+
);
|
101 |
+
await port.open({ baudRate: 1000000 });
|
102 |
+
isConnected = true;
|
103 |
+
} else {
|
104 |
+
console.log(
|
105 |
+
"Port found but no saved robot configuration, skipping auto-connect"
|
106 |
+
);
|
107 |
+
isConnected = false;
|
108 |
+
}
|
109 |
}
|
110 |
} catch (error) {
|
111 |
console.log("Could not auto-connect to paired robot:", error);
|
112 |
isConnected = false;
|
113 |
}
|
114 |
|
115 |
+
// Re-detect serial number for this port
|
116 |
+
let serialNumber = null;
|
117 |
+
let usbMetadata = null;
|
118 |
+
|
119 |
+
// Try to get USB device info to restore serial number
|
120 |
+
try {
|
121 |
+
// Get all USB devices and try to match with this serial port
|
122 |
+
const usbDevices = await navigator.usb.getDevices();
|
123 |
+
const portInfo = port.getInfo();
|
124 |
+
|
125 |
+
// Try to find matching USB device by vendor/product ID
|
126 |
+
const matchingDevice = usbDevices.find(
|
127 |
+
(device) =>
|
128 |
+
device.vendorId === portInfo.usbVendorId &&
|
129 |
+
device.productId === portInfo.usbProductId
|
130 |
+
);
|
131 |
+
|
132 |
+
if (matchingDevice) {
|
133 |
+
serialNumber =
|
134 |
+
matchingDevice.serialNumber ||
|
135 |
+
`${matchingDevice.vendorId}-${
|
136 |
+
matchingDevice.productId
|
137 |
+
}-${Date.now()}`;
|
138 |
+
usbMetadata = {
|
139 |
+
vendorId: `0x${matchingDevice.vendorId
|
140 |
+
.toString(16)
|
141 |
+
.padStart(4, "0")}`,
|
142 |
+
productId: `0x${matchingDevice.productId
|
143 |
+
.toString(16)
|
144 |
+
.padStart(4, "0")}`,
|
145 |
+
serialNumber: matchingDevice.serialNumber || "Generated ID",
|
146 |
+
manufacturerName: matchingDevice.manufacturerName || "Unknown",
|
147 |
+
productName: matchingDevice.productName || "Unknown",
|
148 |
+
usbVersionMajor: matchingDevice.usbVersionMajor,
|
149 |
+
usbVersionMinor: matchingDevice.usbVersionMinor,
|
150 |
+
deviceClass: matchingDevice.deviceClass,
|
151 |
+
deviceSubclass: matchingDevice.deviceSubclass,
|
152 |
+
deviceProtocol: matchingDevice.deviceProtocol,
|
153 |
+
};
|
154 |
+
console.log("✅ Restored USB metadata for port:", serialNumber);
|
155 |
+
}
|
156 |
+
} catch (usbError) {
|
157 |
+
console.log("⚠️ Could not restore USB metadata:", usbError);
|
158 |
+
// Generate fallback if no USB metadata available
|
159 |
+
serialNumber = `fallback-${Date.now()}-${Math.random()
|
160 |
+
.toString(36)
|
161 |
+
.substr(2, 9)}`;
|
162 |
+
}
|
163 |
+
|
164 |
restoredPorts.push({
|
165 |
port,
|
166 |
name: getPortDisplayName(port),
|
167 |
isConnected,
|
168 |
robotType: savedPort?.robotType,
|
169 |
robotId: savedPort?.robotId,
|
170 |
+
serialNumber: serialNumber!,
|
171 |
+
usbMetadata: usbMetadata || undefined,
|
172 |
});
|
173 |
}
|
174 |
|
|
|
303 |
usbMetadata: usbMetadata || undefined,
|
304 |
};
|
305 |
|
306 |
+
// Try to load saved robot info by serial number using unified storage
|
307 |
+
if (serialNumber) {
|
308 |
+
try {
|
309 |
+
const { getRobotConfig } = await import("../lib/unified-storage");
|
310 |
+
const savedConfig = getRobotConfig(serialNumber);
|
311 |
+
if (savedConfig) {
|
312 |
+
newRobot.robotType = savedConfig.robotType as
|
313 |
+
| "so100_follower"
|
314 |
+
| "so100_leader";
|
315 |
+
newRobot.robotId = savedConfig.robotId;
|
316 |
+
console.log("📋 Loaded saved robot configuration:", savedConfig);
|
317 |
+
}
|
318 |
+
} catch (error) {
|
319 |
+
console.warn("Failed to load saved robot data:", error);
|
320 |
}
|
|
|
|
|
321 |
}
|
322 |
|
323 |
onConnectedRobotsChange([...connectedRobots, newRobot]);
|
|
|
352 |
};
|
353 |
|
354 |
const handleDisconnect = async (index: number) => {
|
355 |
+
const portInfo = connectedRobots[index];
|
356 |
+
const robotName = portInfo.robotId || portInfo.name;
|
357 |
+
const serialNumber = portInfo.serialNumber || "unknown";
|
358 |
+
|
359 |
+
// Show confirmation dialog
|
360 |
+
setConfirmDeleteDialog({
|
361 |
+
open: true,
|
362 |
+
robotIndex: index,
|
363 |
+
robotName,
|
364 |
+
serialNumber,
|
365 |
+
});
|
366 |
+
};
|
367 |
+
|
368 |
+
const confirmDelete = async () => {
|
369 |
+
const { robotIndex } = confirmDeleteDialog;
|
370 |
+
const portInfo = connectedRobots[robotIndex];
|
371 |
+
|
372 |
+
setConfirmDeleteDialog({
|
373 |
+
open: false,
|
374 |
+
robotIndex: -1,
|
375 |
+
robotName: "",
|
376 |
+
serialNumber: "",
|
377 |
+
});
|
378 |
+
|
379 |
try {
|
380 |
+
// Close the serial port connection
|
381 |
if (portInfo.isConnected) {
|
382 |
await portInfo.port.close();
|
383 |
}
|
384 |
|
385 |
+
// Delete from unified storage if serial number is available
|
386 |
+
if (portInfo.serialNumber) {
|
387 |
+
try {
|
388 |
+
const { getUnifiedKey } = await import("../lib/unified-storage");
|
389 |
+
const unifiedKey = getUnifiedKey(portInfo.serialNumber);
|
390 |
+
|
391 |
+
// Remove unified storage data
|
392 |
+
localStorage.removeItem(unifiedKey);
|
393 |
+
console.log(`🗑️ Deleted unified robot data: ${unifiedKey}`);
|
394 |
+
|
395 |
+
// Also clean up any old format keys for this robot (if they exist)
|
396 |
+
const oldKeys = [
|
397 |
+
`lerobot-robot-${portInfo.serialNumber}`,
|
398 |
+
`lerobot-calibration-${portInfo.serialNumber}`,
|
399 |
+
];
|
400 |
+
|
401 |
+
// Try to find old calibration key by checking stored robot config
|
402 |
+
if (portInfo.robotType && portInfo.robotId) {
|
403 |
+
oldKeys.push(
|
404 |
+
`lerobot_calibration_${portInfo.robotType}_${portInfo.robotId}`
|
405 |
+
);
|
406 |
+
}
|
407 |
+
|
408 |
+
oldKeys.forEach((key) => {
|
409 |
+
if (localStorage.getItem(key)) {
|
410 |
+
localStorage.removeItem(key);
|
411 |
+
console.log(`🧹 Cleaned up old key: ${key}`);
|
412 |
+
}
|
413 |
+
});
|
414 |
+
} catch (error) {
|
415 |
+
console.warn("Failed to delete unified storage data:", error);
|
416 |
+
|
417 |
+
// Fallback: try to delete old format keys directly
|
418 |
+
if (portInfo.robotType && portInfo.robotId) {
|
419 |
+
const oldKeys = [
|
420 |
+
`lerobot-robot-${portInfo.serialNumber}`,
|
421 |
+
`lerobot-calibration-${portInfo.serialNumber}`,
|
422 |
+
`lerobot_calibration_${portInfo.robotType}_${portInfo.robotId}`,
|
423 |
+
];
|
424 |
+
|
425 |
+
oldKeys.forEach((key) => {
|
426 |
+
if (localStorage.getItem(key)) {
|
427 |
+
localStorage.removeItem(key);
|
428 |
+
console.log(`🧹 Removed old format key: ${key}`);
|
429 |
+
}
|
430 |
+
});
|
431 |
+
}
|
432 |
+
}
|
433 |
+
}
|
434 |
+
|
435 |
+
// Remove from UI
|
436 |
+
const updatedRobots = connectedRobots.filter((_, i) => i !== robotIndex);
|
437 |
onConnectedRobotsChange(updatedRobots);
|
438 |
+
|
439 |
+
console.log(
|
440 |
+
`✅ Robot "${confirmDeleteDialog.robotName}" permanently removed from system`
|
441 |
+
);
|
442 |
} catch (error) {
|
443 |
setError(
|
444 |
+
error instanceof Error ? error.message : "Failed to remove robot"
|
445 |
);
|
446 |
}
|
447 |
};
|
448 |
|
449 |
+
const cancelDelete = () => {
|
450 |
+
setConfirmDeleteDialog({
|
451 |
+
open: false,
|
452 |
+
robotIndex: -1,
|
453 |
+
robotName: "",
|
454 |
+
serialNumber: "",
|
455 |
+
});
|
456 |
+
};
|
457 |
+
|
458 |
const handleUpdatePortInfo = (
|
459 |
index: number,
|
460 |
robotType: "so100_follower" | "so100_leader",
|
|
|
464 |
if (i === index) {
|
465 |
const updatedRobot = { ...robot, robotType, robotId };
|
466 |
|
467 |
+
// Save robot configuration using unified storage
|
468 |
if (updatedRobot.serialNumber) {
|
469 |
+
import("../lib/unified-storage")
|
470 |
+
.then(({ saveRobotConfig }) => {
|
471 |
+
saveRobotConfig(
|
472 |
+
updatedRobot.serialNumber!,
|
473 |
+
robotType,
|
474 |
+
robotId,
|
475 |
+
updatedRobot.usbMetadata
|
476 |
+
);
|
477 |
+
console.log(
|
478 |
+
"💾 Saved robot configuration for:",
|
479 |
+
updatedRobot.serialNumber
|
480 |
+
);
|
481 |
+
})
|
482 |
+
.catch((error) => {
|
483 |
+
console.warn("Failed to save robot configuration:", error);
|
484 |
+
});
|
485 |
}
|
486 |
|
487 |
return updatedRobot;
|
|
|
690 |
</div>
|
691 |
</div>
|
692 |
</CardContent>
|
693 |
+
|
694 |
+
{/* Confirmation Dialog */}
|
695 |
+
<Dialog open={confirmDeleteDialog.open} onOpenChange={cancelDelete}>
|
696 |
+
<DialogContent>
|
697 |
+
<DialogHeader>
|
698 |
+
<DialogTitle>🗑️ Permanently Delete Robot Data?</DialogTitle>
|
699 |
+
<DialogDescription>
|
700 |
+
This action cannot be undone. All robot data will be permanently
|
701 |
+
deleted.
|
702 |
+
</DialogDescription>
|
703 |
+
</DialogHeader>
|
704 |
+
|
705 |
+
<div className="space-y-3">
|
706 |
+
<div className="p-4 bg-red-50 rounded-lg border border-red-200">
|
707 |
+
<div className="font-medium text-red-900 mb-2">
|
708 |
+
Robot Information:
|
709 |
+
</div>
|
710 |
+
<div className="text-sm text-red-800 space-y-1">
|
711 |
+
<div>
|
712 |
+
• Name:{" "}
|
713 |
+
<span className="font-mono">
|
714 |
+
{confirmDeleteDialog.robotName}
|
715 |
+
</span>
|
716 |
+
</div>
|
717 |
+
<div>
|
718 |
+
• Serial:{" "}
|
719 |
+
<span className="font-mono">
|
720 |
+
{confirmDeleteDialog.serialNumber}
|
721 |
+
</span>
|
722 |
+
</div>
|
723 |
+
</div>
|
724 |
+
</div>
|
725 |
+
|
726 |
+
<div className="p-4 bg-red-50 rounded-lg border border-red-200">
|
727 |
+
<div className="font-medium text-red-900 mb-2">
|
728 |
+
This will permanently delete:
|
729 |
+
</div>
|
730 |
+
<div className="text-sm text-red-800 space-y-1">
|
731 |
+
<div>• Robot configuration</div>
|
732 |
+
<div>• Calibration data</div>
|
733 |
+
<div>• All saved settings</div>
|
734 |
+
</div>
|
735 |
+
</div>
|
736 |
+
</div>
|
737 |
+
|
738 |
+
<DialogFooter>
|
739 |
+
<Button variant="outline" onClick={cancelDelete}>
|
740 |
+
Cancel
|
741 |
+
</Button>
|
742 |
+
<Button variant="destructive" onClick={confirmDelete}>
|
743 |
+
Delete Forever
|
744 |
+
</Button>
|
745 |
+
</DialogFooter>
|
746 |
+
</DialogContent>
|
747 |
+
</Dialog>
|
748 |
</Card>
|
749 |
);
|
750 |
}
|
|
|
775 |
const [portMetadata, setPortMetadata] = useState<any>(null);
|
776 |
const [showDeviceInfo, setShowDeviceInfo] = useState(false);
|
777 |
|
778 |
+
// Check for calibration using unified storage
|
779 |
const getCalibrationStatus = () => {
|
780 |
+
// Use the same serial number logic as calibration: prefer main serialNumber, fallback to USB metadata, then "unknown"
|
781 |
+
const serialNumber =
|
782 |
+
portInfo.serialNumber || portInfo.usbMetadata?.serialNumber || "unknown";
|
783 |
|
|
|
784 |
try {
|
785 |
+
// Use unified storage system with automatic migration
|
786 |
+
import("../lib/unified-storage")
|
787 |
+
.then(({ getCalibrationStatus }) => {
|
788 |
+
const status = getCalibrationStatus(serialNumber);
|
789 |
+
return status;
|
790 |
+
})
|
791 |
+
.catch((error) => {
|
792 |
+
console.warn("Failed to load unified calibration data:", error);
|
793 |
+
return null;
|
794 |
+
});
|
795 |
+
|
796 |
+
// For immediate synchronous return, try to get existing unified data first
|
797 |
+
const unifiedKey = `lerobotjs-${serialNumber}`;
|
798 |
+
const existing = localStorage.getItem(unifiedKey);
|
799 |
+
if (existing) {
|
800 |
+
const data = JSON.parse(existing);
|
801 |
+
if (data.calibration?.metadata) {
|
802 |
+
return {
|
803 |
+
timestamp: data.calibration.metadata.timestamp,
|
804 |
+
readCount: data.calibration.metadata.readCount,
|
805 |
+
};
|
806 |
+
}
|
807 |
}
|
808 |
} catch (error) {
|
809 |
+
console.warn("Failed to read calibration from unified storage:", error);
|
810 |
}
|
811 |
return null;
|
812 |
};
|
|
|
1058 |
<Badge variant={portInfo.isConnected ? "default" : "outline"}>
|
1059 |
{portInfo.isConnected ? "Connected" : "Available"}
|
1060 |
</Badge>
|
|
|
|
|
|
|
|
|
|
|
1061 |
</div>
|
1062 |
<Button variant="destructive" size="sm" onClick={onDisconnect}>
|
1063 |
Remove
|
src/demo/components/ui/dialog.tsx
ADDED
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import * as React from "react";
|
2 |
+
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
3 |
+
import { X } from "lucide-react";
|
4 |
+
|
5 |
+
import { cn } from "../../lib/utils";
|
6 |
+
|
7 |
+
const Dialog = DialogPrimitive.Root;
|
8 |
+
|
9 |
+
const DialogTrigger = DialogPrimitive.Trigger;
|
10 |
+
|
11 |
+
const DialogPortal = DialogPrimitive.Portal;
|
12 |
+
|
13 |
+
const DialogClose = DialogPrimitive.Close;
|
14 |
+
|
15 |
+
const DialogOverlay = React.forwardRef<
|
16 |
+
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
17 |
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
18 |
+
>(({ className, ...props }, ref) => (
|
19 |
+
<DialogPrimitive.Overlay
|
20 |
+
ref={ref}
|
21 |
+
className={cn(
|
22 |
+
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
23 |
+
className
|
24 |
+
)}
|
25 |
+
{...props}
|
26 |
+
/>
|
27 |
+
));
|
28 |
+
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
29 |
+
|
30 |
+
const DialogContent = React.forwardRef<
|
31 |
+
React.ElementRef<typeof DialogPrimitive.Content>,
|
32 |
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
33 |
+
>(({ className, children, ...props }, ref) => (
|
34 |
+
<DialogPortal>
|
35 |
+
<DialogOverlay />
|
36 |
+
<DialogPrimitive.Content
|
37 |
+
ref={ref}
|
38 |
+
className={cn(
|
39 |
+
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
40 |
+
className
|
41 |
+
)}
|
42 |
+
{...props}
|
43 |
+
>
|
44 |
+
{children}
|
45 |
+
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
46 |
+
<X className="h-4 w-4" />
|
47 |
+
<span className="sr-only">Close</span>
|
48 |
+
</DialogPrimitive.Close>
|
49 |
+
</DialogPrimitive.Content>
|
50 |
+
</DialogPortal>
|
51 |
+
));
|
52 |
+
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
53 |
+
|
54 |
+
const DialogHeader = ({
|
55 |
+
className,
|
56 |
+
...props
|
57 |
+
}: React.HTMLAttributes<HTMLDivElement>) => (
|
58 |
+
<div
|
59 |
+
className={cn(
|
60 |
+
"flex flex-col space-y-1.5 text-center sm:text-left",
|
61 |
+
className
|
62 |
+
)}
|
63 |
+
{...props}
|
64 |
+
/>
|
65 |
+
);
|
66 |
+
DialogHeader.displayName = "DialogHeader";
|
67 |
+
|
68 |
+
const DialogFooter = ({
|
69 |
+
className,
|
70 |
+
...props
|
71 |
+
}: React.HTMLAttributes<HTMLDivElement>) => (
|
72 |
+
<div
|
73 |
+
className={cn(
|
74 |
+
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
75 |
+
className
|
76 |
+
)}
|
77 |
+
{...props}
|
78 |
+
/>
|
79 |
+
);
|
80 |
+
DialogFooter.displayName = "DialogFooter";
|
81 |
+
|
82 |
+
const DialogTitle = React.forwardRef<
|
83 |
+
React.ElementRef<typeof DialogPrimitive.Title>,
|
84 |
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
85 |
+
>(({ className, ...props }, ref) => (
|
86 |
+
<DialogPrimitive.Title
|
87 |
+
ref={ref}
|
88 |
+
className={cn(
|
89 |
+
"text-lg font-semibold leading-none tracking-tight",
|
90 |
+
className
|
91 |
+
)}
|
92 |
+
{...props}
|
93 |
+
/>
|
94 |
+
));
|
95 |
+
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
96 |
+
|
97 |
+
const DialogDescription = React.forwardRef<
|
98 |
+
React.ElementRef<typeof DialogPrimitive.Description>,
|
99 |
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
100 |
+
>(({ className, ...props }, ref) => (
|
101 |
+
<DialogPrimitive.Description
|
102 |
+
ref={ref}
|
103 |
+
className={cn("text-sm text-muted-foreground", className)}
|
104 |
+
{...props}
|
105 |
+
/>
|
106 |
+
));
|
107 |
+
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
108 |
+
|
109 |
+
export {
|
110 |
+
Dialog,
|
111 |
+
DialogPortal,
|
112 |
+
DialogOverlay,
|
113 |
+
DialogClose,
|
114 |
+
DialogContent,
|
115 |
+
DialogDescription,
|
116 |
+
DialogFooter,
|
117 |
+
DialogHeader,
|
118 |
+
DialogTitle,
|
119 |
+
DialogTrigger,
|
120 |
+
};
|
src/demo/components/ui/progress.tsx
ADDED
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import * as React from "react";
|
2 |
+
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
3 |
+
|
4 |
+
import { cn } from "../../lib/utils";
|
5 |
+
|
6 |
+
const Progress = React.forwardRef<
|
7 |
+
React.ElementRef<typeof ProgressPrimitive.Root>,
|
8 |
+
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
9 |
+
>(({ className, value, ...props }, ref) => (
|
10 |
+
<ProgressPrimitive.Root
|
11 |
+
ref={ref}
|
12 |
+
className={cn(
|
13 |
+
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
14 |
+
className
|
15 |
+
)}
|
16 |
+
{...props}
|
17 |
+
>
|
18 |
+
<ProgressPrimitive.Indicator
|
19 |
+
className="h-full w-full flex-1 bg-primary transition-all"
|
20 |
+
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
21 |
+
/>
|
22 |
+
</ProgressPrimitive.Root>
|
23 |
+
));
|
24 |
+
Progress.displayName = ProgressPrimitive.Root.displayName;
|
25 |
+
|
26 |
+
export { Progress };
|
src/demo/lib/unified-storage.ts
ADDED
@@ -0,0 +1,325 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
// Unified storage system for robot data
|
2 |
+
// Consolidates robot config, calibration data, and metadata under one key per device
|
3 |
+
|
4 |
+
export interface UnifiedRobotData {
|
5 |
+
device_info: {
|
6 |
+
serialNumber: string;
|
7 |
+
robotType: "so100_follower" | "so100_leader";
|
8 |
+
robotId: string;
|
9 |
+
usbMetadata?: any;
|
10 |
+
lastUpdated: string;
|
11 |
+
};
|
12 |
+
calibration?: {
|
13 |
+
// Motor calibration data (from lerobot_calibration_* keys)
|
14 |
+
shoulder_pan?: {
|
15 |
+
id: number;
|
16 |
+
drive_mode: number;
|
17 |
+
homing_offset: number;
|
18 |
+
range_min: number;
|
19 |
+
range_max: number;
|
20 |
+
};
|
21 |
+
shoulder_lift?: {
|
22 |
+
id: number;
|
23 |
+
drive_mode: number;
|
24 |
+
homing_offset: number;
|
25 |
+
range_min: number;
|
26 |
+
range_max: number;
|
27 |
+
};
|
28 |
+
elbow_flex?: {
|
29 |
+
id: number;
|
30 |
+
drive_mode: number;
|
31 |
+
homing_offset: number;
|
32 |
+
range_min: number;
|
33 |
+
range_max: number;
|
34 |
+
};
|
35 |
+
wrist_flex?: {
|
36 |
+
id: number;
|
37 |
+
drive_mode: number;
|
38 |
+
homing_offset: number;
|
39 |
+
range_min: number;
|
40 |
+
range_max: number;
|
41 |
+
};
|
42 |
+
wrist_roll?: {
|
43 |
+
id: number;
|
44 |
+
drive_mode: number;
|
45 |
+
homing_offset: number;
|
46 |
+
range_min: number;
|
47 |
+
range_max: number;
|
48 |
+
};
|
49 |
+
gripper?: {
|
50 |
+
id: number;
|
51 |
+
drive_mode: number;
|
52 |
+
homing_offset: number;
|
53 |
+
range_min: number;
|
54 |
+
range_max: number;
|
55 |
+
};
|
56 |
+
|
57 |
+
// Calibration metadata (from lerobot-calibration-* keys)
|
58 |
+
metadata: {
|
59 |
+
timestamp: string;
|
60 |
+
readCount: number;
|
61 |
+
platform: string;
|
62 |
+
api: string;
|
63 |
+
device_type: string;
|
64 |
+
device_id: string;
|
65 |
+
calibrated_at: string;
|
66 |
+
};
|
67 |
+
};
|
68 |
+
}
|
69 |
+
|
70 |
+
/**
|
71 |
+
* Get unified storage key for a robot by serial number
|
72 |
+
*/
|
73 |
+
export function getUnifiedKey(serialNumber: string): string {
|
74 |
+
return `lerobotjs-${serialNumber}`;
|
75 |
+
}
|
76 |
+
|
77 |
+
/**
|
78 |
+
* Migrate data from old storage keys to unified format
|
79 |
+
* Safely combines data from three sources:
|
80 |
+
* 1. lerobot-robot-{serialNumber} - robot config
|
81 |
+
* 2. lerobot-calibration-{serialNumber} - calibration metadata
|
82 |
+
* 3. lerobot_calibration_{robotType}_{robotId} - actual calibration data
|
83 |
+
*/
|
84 |
+
export function migrateToUnifiedStorage(
|
85 |
+
serialNumber: string
|
86 |
+
): UnifiedRobotData | null {
|
87 |
+
try {
|
88 |
+
const unifiedKey = getUnifiedKey(serialNumber);
|
89 |
+
|
90 |
+
// Check if already migrated
|
91 |
+
const existing = localStorage.getItem(unifiedKey);
|
92 |
+
if (existing) {
|
93 |
+
console.log(`✅ Data already unified for ${serialNumber}`);
|
94 |
+
return JSON.parse(existing);
|
95 |
+
}
|
96 |
+
|
97 |
+
console.log(`🔄 Migrating data for serial number: ${serialNumber}`);
|
98 |
+
|
99 |
+
// 1. Get robot configuration
|
100 |
+
const robotConfigKey = `lerobot-robot-${serialNumber}`;
|
101 |
+
const robotConfigRaw = localStorage.getItem(robotConfigKey);
|
102 |
+
|
103 |
+
if (!robotConfigRaw) {
|
104 |
+
return null;
|
105 |
+
}
|
106 |
+
|
107 |
+
const robotConfig = JSON.parse(robotConfigRaw);
|
108 |
+
console.log(`📋 Found robot config:`, robotConfig);
|
109 |
+
|
110 |
+
// 2. Get calibration metadata
|
111 |
+
const calibrationMetaKey = `lerobot-calibration-${serialNumber}`;
|
112 |
+
const calibrationMetaRaw = localStorage.getItem(calibrationMetaKey);
|
113 |
+
const calibrationMeta = calibrationMetaRaw
|
114 |
+
? JSON.parse(calibrationMetaRaw)
|
115 |
+
: null;
|
116 |
+
console.log(`📊 Found calibration metadata:`, calibrationMeta);
|
117 |
+
|
118 |
+
// 3. Get actual calibration data (using robotType and robotId from config)
|
119 |
+
const calibrationDataKey = `lerobot_calibration_${robotConfig.robotType}_${robotConfig.robotId}`;
|
120 |
+
const calibrationDataRaw = localStorage.getItem(calibrationDataKey);
|
121 |
+
const calibrationData = calibrationDataRaw
|
122 |
+
? JSON.parse(calibrationDataRaw)
|
123 |
+
: null;
|
124 |
+
console.log(`🔧 Found calibration data:`, calibrationData);
|
125 |
+
|
126 |
+
// 4. Build unified structure
|
127 |
+
const unifiedData: UnifiedRobotData = {
|
128 |
+
device_info: {
|
129 |
+
serialNumber: robotConfig.serialNumber || serialNumber,
|
130 |
+
robotType: robotConfig.robotType,
|
131 |
+
robotId: robotConfig.robotId,
|
132 |
+
lastUpdated: robotConfig.lastUpdated || new Date().toISOString(),
|
133 |
+
},
|
134 |
+
};
|
135 |
+
|
136 |
+
// Add calibration if available
|
137 |
+
if (calibrationData && calibrationMeta) {
|
138 |
+
const motors: any = {};
|
139 |
+
|
140 |
+
// Copy motor data (excluding metadata fields)
|
141 |
+
Object.keys(calibrationData).forEach((key) => {
|
142 |
+
if (
|
143 |
+
![
|
144 |
+
"device_type",
|
145 |
+
"device_id",
|
146 |
+
"calibrated_at",
|
147 |
+
"platform",
|
148 |
+
"api",
|
149 |
+
].includes(key)
|
150 |
+
) {
|
151 |
+
motors[key] = calibrationData[key];
|
152 |
+
}
|
153 |
+
});
|
154 |
+
|
155 |
+
unifiedData.calibration = {
|
156 |
+
...motors,
|
157 |
+
metadata: {
|
158 |
+
timestamp: calibrationMeta.timestamp || calibrationData.calibrated_at,
|
159 |
+
readCount: calibrationMeta.readCount || 0,
|
160 |
+
platform: calibrationData.platform || "web",
|
161 |
+
api: calibrationData.api || "Web Serial API",
|
162 |
+
device_type: calibrationData.device_type || robotConfig.robotType,
|
163 |
+
device_id: calibrationData.device_id || robotConfig.robotId,
|
164 |
+
calibrated_at:
|
165 |
+
calibrationData.calibrated_at || calibrationMeta.timestamp,
|
166 |
+
},
|
167 |
+
};
|
168 |
+
}
|
169 |
+
|
170 |
+
// 5. Save unified data
|
171 |
+
localStorage.setItem(unifiedKey, JSON.stringify(unifiedData));
|
172 |
+
console.log(`✅ Successfully unified data for ${serialNumber}`);
|
173 |
+
console.log(`📦 Unified data:`, unifiedData);
|
174 |
+
|
175 |
+
// 6. Clean up old keys (optional - keep for now for safety)
|
176 |
+
// localStorage.removeItem(robotConfigKey);
|
177 |
+
// localStorage.removeItem(calibrationMetaKey);
|
178 |
+
// localStorage.removeItem(calibrationDataKey);
|
179 |
+
|
180 |
+
return unifiedData;
|
181 |
+
} catch (error) {
|
182 |
+
console.error(`❌ Failed to migrate data for ${serialNumber}:`, error);
|
183 |
+
return null;
|
184 |
+
}
|
185 |
+
}
|
186 |
+
|
187 |
+
/**
|
188 |
+
* Get unified robot data
|
189 |
+
*/
|
190 |
+
export function getUnifiedRobotData(
|
191 |
+
serialNumber: string
|
192 |
+
): UnifiedRobotData | null {
|
193 |
+
const unifiedKey = getUnifiedKey(serialNumber);
|
194 |
+
|
195 |
+
// Try to get existing unified data
|
196 |
+
const existing = localStorage.getItem(unifiedKey);
|
197 |
+
if (existing) {
|
198 |
+
try {
|
199 |
+
return JSON.parse(existing);
|
200 |
+
} catch (error) {
|
201 |
+
console.warn(`Failed to parse unified data for ${serialNumber}:`, error);
|
202 |
+
}
|
203 |
+
}
|
204 |
+
|
205 |
+
return null;
|
206 |
+
}
|
207 |
+
|
208 |
+
/**
|
209 |
+
* Save robot configuration to unified storage
|
210 |
+
*/
|
211 |
+
export function saveRobotConfig(
|
212 |
+
serialNumber: string,
|
213 |
+
robotType: "so100_follower" | "so100_leader",
|
214 |
+
robotId: string,
|
215 |
+
usbMetadata?: any
|
216 |
+
): void {
|
217 |
+
const unifiedKey = getUnifiedKey(serialNumber);
|
218 |
+
const existing =
|
219 |
+
getUnifiedRobotData(serialNumber) || ({} as UnifiedRobotData);
|
220 |
+
|
221 |
+
existing.device_info = {
|
222 |
+
serialNumber,
|
223 |
+
robotType,
|
224 |
+
robotId,
|
225 |
+
usbMetadata,
|
226 |
+
lastUpdated: new Date().toISOString(),
|
227 |
+
};
|
228 |
+
|
229 |
+
localStorage.setItem(unifiedKey, JSON.stringify(existing));
|
230 |
+
console.log(`💾 Saved robot config for ${serialNumber}`);
|
231 |
+
}
|
232 |
+
|
233 |
+
/**
|
234 |
+
* Save calibration data to unified storage
|
235 |
+
*/
|
236 |
+
export function saveCalibrationData(
|
237 |
+
serialNumber: string,
|
238 |
+
calibrationData: any,
|
239 |
+
metadata: { timestamp: string; readCount: number }
|
240 |
+
): void {
|
241 |
+
const unifiedKey = getUnifiedKey(serialNumber);
|
242 |
+
const existing =
|
243 |
+
getUnifiedRobotData(serialNumber) || ({} as UnifiedRobotData);
|
244 |
+
|
245 |
+
// Ensure device_info exists
|
246 |
+
if (!existing.device_info) {
|
247 |
+
console.warn(
|
248 |
+
`No device info found for ${serialNumber}, cannot save calibration`
|
249 |
+
);
|
250 |
+
return;
|
251 |
+
}
|
252 |
+
|
253 |
+
// Extract motor data (exclude metadata fields)
|
254 |
+
const motors: any = {};
|
255 |
+
Object.keys(calibrationData).forEach((key) => {
|
256 |
+
if (
|
257 |
+
![
|
258 |
+
"device_type",
|
259 |
+
"device_id",
|
260 |
+
"calibrated_at",
|
261 |
+
"platform",
|
262 |
+
"api",
|
263 |
+
].includes(key)
|
264 |
+
) {
|
265 |
+
motors[key] = calibrationData[key];
|
266 |
+
}
|
267 |
+
});
|
268 |
+
|
269 |
+
existing.calibration = {
|
270 |
+
...motors,
|
271 |
+
metadata: {
|
272 |
+
timestamp: metadata.timestamp,
|
273 |
+
readCount: metadata.readCount,
|
274 |
+
platform: calibrationData.platform || "web",
|
275 |
+
api: calibrationData.api || "Web Serial API",
|
276 |
+
device_type:
|
277 |
+
calibrationData.device_type || existing.device_info.robotType,
|
278 |
+
device_id: calibrationData.device_id || existing.device_info.robotId,
|
279 |
+
calibrated_at: calibrationData.calibrated_at || metadata.timestamp,
|
280 |
+
},
|
281 |
+
};
|
282 |
+
|
283 |
+
localStorage.setItem(unifiedKey, JSON.stringify(existing));
|
284 |
+
console.log(`🔧 Saved calibration data for ${serialNumber}`);
|
285 |
+
}
|
286 |
+
|
287 |
+
/**
|
288 |
+
* Check if robot is calibrated
|
289 |
+
*/
|
290 |
+
export function isRobotCalibrated(serialNumber: string): boolean {
|
291 |
+
const data = getUnifiedRobotData(serialNumber);
|
292 |
+
return !!data?.calibration?.metadata?.timestamp;
|
293 |
+
}
|
294 |
+
|
295 |
+
/**
|
296 |
+
* Get calibration status for dashboard
|
297 |
+
*/
|
298 |
+
export function getCalibrationStatus(
|
299 |
+
serialNumber: string
|
300 |
+
): { timestamp: string; readCount: number } | null {
|
301 |
+
const data = getUnifiedRobotData(serialNumber);
|
302 |
+
if (data?.calibration?.metadata) {
|
303 |
+
return {
|
304 |
+
timestamp: data.calibration.metadata.timestamp,
|
305 |
+
readCount: data.calibration.metadata.readCount,
|
306 |
+
};
|
307 |
+
}
|
308 |
+
return null;
|
309 |
+
}
|
310 |
+
|
311 |
+
/**
|
312 |
+
* Get robot configuration
|
313 |
+
*/
|
314 |
+
export function getRobotConfig(
|
315 |
+
serialNumber: string
|
316 |
+
): { robotType: string; robotId: string } | null {
|
317 |
+
const data = getUnifiedRobotData(serialNumber);
|
318 |
+
if (data?.device_info) {
|
319 |
+
return {
|
320 |
+
robotType: data.device_info.robotType,
|
321 |
+
robotId: data.device_info.robotId,
|
322 |
+
};
|
323 |
+
}
|
324 |
+
return null;
|
325 |
+
}
|
src/demo/main.tsx
CHANGED
@@ -3,8 +3,4 @@ import ReactDOM from "react-dom/client";
|
|
3 |
import { App } from "./App";
|
4 |
import "./index.css";
|
5 |
|
6 |
-
ReactDOM.createRoot(document.getElementById("root")!).render(
|
7 |
-
<React.StrictMode>
|
8 |
-
<App />
|
9 |
-
</React.StrictMode>
|
10 |
-
);
|
|
|
3 |
import { App } from "./App";
|
4 |
import "./index.css";
|
5 |
|
6 |
+
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);
|
|
|
|
|
|
|
|
src/demo/pages/Calibrate.tsx
DELETED
@@ -1,121 +0,0 @@
|
|
1 |
-
import React, { useState, useEffect } from "react";
|
2 |
-
import { Button } from "../components/ui/button";
|
3 |
-
import {
|
4 |
-
Card,
|
5 |
-
CardContent,
|
6 |
-
CardDescription,
|
7 |
-
CardHeader,
|
8 |
-
CardTitle,
|
9 |
-
} from "../components/ui/card";
|
10 |
-
import { Alert, AlertDescription } from "../components/ui/alert";
|
11 |
-
import { Badge } from "../components/ui/badge";
|
12 |
-
import { CalibrationWizard } from "../components/CalibrationWizard";
|
13 |
-
import type { ConnectedRobot } from "../types";
|
14 |
-
|
15 |
-
interface CalibrateProps {
|
16 |
-
selectedRobot: ConnectedRobot;
|
17 |
-
onBack: () => void;
|
18 |
-
onHome: () => void;
|
19 |
-
}
|
20 |
-
|
21 |
-
export function Calibrate({ selectedRobot, onBack, onHome }: CalibrateProps) {
|
22 |
-
const [calibrationStarted, setCalibrationStarted] = useState(false);
|
23 |
-
|
24 |
-
// Auto-start calibration when component mounts
|
25 |
-
useEffect(() => {
|
26 |
-
if (selectedRobot && selectedRobot.isConnected) {
|
27 |
-
setCalibrationStarted(true);
|
28 |
-
}
|
29 |
-
}, [selectedRobot]);
|
30 |
-
|
31 |
-
if (!selectedRobot) {
|
32 |
-
return (
|
33 |
-
<div className="container mx-auto px-4 py-8">
|
34 |
-
<Alert variant="destructive">
|
35 |
-
<AlertDescription>
|
36 |
-
No robot selected. Please go back to setup.
|
37 |
-
</AlertDescription>
|
38 |
-
</Alert>
|
39 |
-
<div className="mt-4">
|
40 |
-
<Button onClick={onBack}>Back to Setup</Button>
|
41 |
-
</div>
|
42 |
-
</div>
|
43 |
-
);
|
44 |
-
}
|
45 |
-
|
46 |
-
return (
|
47 |
-
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
48 |
-
<div className="space-y-6">
|
49 |
-
<div className="text-center space-y-2">
|
50 |
-
<h1 className="text-3xl font-bold">Robot Calibration</h1>
|
51 |
-
<p className="text-muted-foreground">
|
52 |
-
Calibrating: {selectedRobot.robotId}
|
53 |
-
</p>
|
54 |
-
</div>
|
55 |
-
|
56 |
-
<Card>
|
57 |
-
<CardHeader>
|
58 |
-
<div className="flex items-center justify-between">
|
59 |
-
<div>
|
60 |
-
<CardTitle className="text-xl">
|
61 |
-
{selectedRobot.robotId}
|
62 |
-
</CardTitle>
|
63 |
-
<CardDescription>{selectedRobot.name}</CardDescription>
|
64 |
-
</div>
|
65 |
-
<div className="flex items-center space-x-2">
|
66 |
-
<Badge
|
67 |
-
variant={selectedRobot.isConnected ? "default" : "secondary"}
|
68 |
-
>
|
69 |
-
{selectedRobot.isConnected ? "Connected" : "Disconnected"}
|
70 |
-
</Badge>
|
71 |
-
<Badge variant="outline">
|
72 |
-
{selectedRobot.robotType?.replace("_", " ")}
|
73 |
-
</Badge>
|
74 |
-
</div>
|
75 |
-
</div>
|
76 |
-
</CardHeader>
|
77 |
-
<CardContent>
|
78 |
-
{!selectedRobot.isConnected ? (
|
79 |
-
<Alert variant="destructive">
|
80 |
-
<AlertDescription>
|
81 |
-
Robot is not connected. Please check connection and try again.
|
82 |
-
</AlertDescription>
|
83 |
-
</Alert>
|
84 |
-
) : calibrationStarted ? (
|
85 |
-
<CalibrationWizard
|
86 |
-
robot={selectedRobot}
|
87 |
-
onComplete={onHome}
|
88 |
-
onCancel={onBack}
|
89 |
-
/>
|
90 |
-
) : (
|
91 |
-
<div className="space-y-4">
|
92 |
-
<div className="text-center py-8">
|
93 |
-
<div className="text-2xl mb-4">🛠️</div>
|
94 |
-
<h3 className="text-lg font-semibold mb-2">
|
95 |
-
Ready to Calibrate
|
96 |
-
</h3>
|
97 |
-
<p className="text-muted-foreground mb-4">
|
98 |
-
Make sure your robot arm is in a safe position and you have
|
99 |
-
a clear workspace.
|
100 |
-
</p>
|
101 |
-
<Button onClick={() => setCalibrationStarted(true)} size="lg">
|
102 |
-
Start Calibration
|
103 |
-
</Button>
|
104 |
-
</div>
|
105 |
-
</div>
|
106 |
-
)}
|
107 |
-
</CardContent>
|
108 |
-
</Card>
|
109 |
-
|
110 |
-
<div className="flex justify-center space-x-4">
|
111 |
-
<Button variant="outline" onClick={onBack}>
|
112 |
-
Back to Setup
|
113 |
-
</Button>
|
114 |
-
<Button variant="outline" onClick={onHome}>
|
115 |
-
Back to Home
|
116 |
-
</Button>
|
117 |
-
</div>
|
118 |
-
</div>
|
119 |
-
</div>
|
120 |
-
);
|
121 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/demo/pages/Setup.tsx
DELETED
@@ -1,99 +0,0 @@
|
|
1 |
-
import React from "react";
|
2 |
-
import { Button } from "../components/ui/button";
|
3 |
-
import {
|
4 |
-
Card,
|
5 |
-
CardContent,
|
6 |
-
CardDescription,
|
7 |
-
CardHeader,
|
8 |
-
CardTitle,
|
9 |
-
} from "../components/ui/card";
|
10 |
-
import { Alert, AlertDescription, AlertTitle } from "../components/ui/alert";
|
11 |
-
import { Badge } from "../components/ui/badge";
|
12 |
-
import type { ConnectedRobot } from "../types";
|
13 |
-
|
14 |
-
interface SetupProps {
|
15 |
-
connectedRobots: ConnectedRobot[];
|
16 |
-
onBack: () => void;
|
17 |
-
onNext: (robot: ConnectedRobot) => void;
|
18 |
-
}
|
19 |
-
|
20 |
-
export function Setup({ connectedRobots, onBack, onNext }: SetupProps) {
|
21 |
-
const configuredRobots = connectedRobots.filter(
|
22 |
-
(r) => r.robotType && r.robotId
|
23 |
-
);
|
24 |
-
|
25 |
-
return (
|
26 |
-
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
27 |
-
<div className="space-y-6">
|
28 |
-
<div className="text-center space-y-2">
|
29 |
-
<h1 className="text-3xl font-bold">Robot Setup</h1>
|
30 |
-
<p className="text-muted-foreground">
|
31 |
-
Select a connected robot to calibrate
|
32 |
-
</p>
|
33 |
-
</div>
|
34 |
-
|
35 |
-
<div className="space-y-4">
|
36 |
-
<div className="flex items-center justify-between">
|
37 |
-
<h2 className="text-xl font-semibold">Connected Robots</h2>
|
38 |
-
<Badge variant="outline">{configuredRobots.length} ready</Badge>
|
39 |
-
</div>
|
40 |
-
|
41 |
-
{configuredRobots.length === 0 ? (
|
42 |
-
<Card>
|
43 |
-
<CardContent className="text-center py-8">
|
44 |
-
<div className="text-muted-foreground space-y-2">
|
45 |
-
<p>No configured robots found.</p>
|
46 |
-
<p className="text-sm">
|
47 |
-
Go back to the home page to connect and configure your
|
48 |
-
robots.
|
49 |
-
</p>
|
50 |
-
</div>
|
51 |
-
</CardContent>
|
52 |
-
</Card>
|
53 |
-
) : (
|
54 |
-
<div className="grid gap-4">
|
55 |
-
{configuredRobots.map((robot, index) => (
|
56 |
-
<Card
|
57 |
-
key={index}
|
58 |
-
className="cursor-pointer hover:shadow-md transition-shadow"
|
59 |
-
>
|
60 |
-
<CardHeader>
|
61 |
-
<div className="flex items-center justify-between">
|
62 |
-
<div>
|
63 |
-
<CardTitle className="text-lg">
|
64 |
-
{robot.robotId}
|
65 |
-
</CardTitle>
|
66 |
-
<CardDescription>{robot.name}</CardDescription>
|
67 |
-
</div>
|
68 |
-
<div className="flex items-center space-x-2">
|
69 |
-
<Badge
|
70 |
-
variant={robot.isConnected ? "default" : "outline"}
|
71 |
-
>
|
72 |
-
{robot.isConnected ? "Connected" : "Available"}
|
73 |
-
</Badge>
|
74 |
-
<Badge variant="outline">
|
75 |
-
{robot.robotType?.replace("_", " ")}
|
76 |
-
</Badge>
|
77 |
-
</div>
|
78 |
-
</div>
|
79 |
-
</CardHeader>
|
80 |
-
<CardContent>
|
81 |
-
<Button onClick={() => onNext(robot)} className="w-full">
|
82 |
-
Calibrate This Robot
|
83 |
-
</Button>
|
84 |
-
</CardContent>
|
85 |
-
</Card>
|
86 |
-
))}
|
87 |
-
</div>
|
88 |
-
)}
|
89 |
-
</div>
|
90 |
-
|
91 |
-
<div className="flex justify-center">
|
92 |
-
<Button variant="outline" onClick={onBack}>
|
93 |
-
Back to Home
|
94 |
-
</Button>
|
95 |
-
</div>
|
96 |
-
</div>
|
97 |
-
</div>
|
98 |
-
);
|
99 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/lerobot/web/calibrate.ts
CHANGED
@@ -1,334 +1,808 @@
|
|
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 |
-
*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
22 |
}
|
23 |
|
24 |
async initialize(): Promise<void> {
|
25 |
-
|
26 |
-
|
27 |
-
this.reader = this.port.readable.getReader();
|
28 |
-
}
|
29 |
-
if (this.port.writable) {
|
30 |
-
this.writer = this.port.writable.getWriter();
|
31 |
}
|
32 |
}
|
33 |
|
34 |
-
|
35 |
-
|
|
|
|
|
|
|
|
|
36 |
throw new Error("Port not open for writing");
|
37 |
}
|
38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
39 |
}
|
40 |
|
41 |
-
|
42 |
-
|
|
|
|
|
|
|
|
|
43 |
throw new Error("Port not open for reading");
|
44 |
}
|
45 |
|
46 |
-
|
47 |
-
const timer = setTimeout(() => {
|
48 |
-
reject(new Error("Read timeout"));
|
49 |
-
}, timeout);
|
50 |
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
} else {
|
57 |
-
resolve(Buffer.from(value));
|
58 |
-
}
|
59 |
-
})
|
60 |
-
.catch(reject);
|
61 |
-
});
|
62 |
-
}
|
63 |
|
64 |
-
|
65 |
-
|
66 |
-
|
67 |
-
|
68 |
-
|
69 |
-
|
70 |
-
|
71 |
-
|
|
|
|
|
|
|
|
|
|
|
72 |
}
|
73 |
-
// Don't close the port itself - let the UI manage that
|
74 |
}
|
75 |
-
}
|
76 |
|
77 |
-
|
78 |
-
|
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
|
96 |
*/
|
97 |
async function readMotorPositions(
|
98 |
-
config:
|
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 |
-
|
104 |
-
|
|
|
|
|
|
|
105 |
|
106 |
try {
|
107 |
-
// Create
|
108 |
-
const packet =
|
109 |
0xff,
|
110 |
0xff,
|
111 |
motorId,
|
112 |
0x04,
|
113 |
0x02,
|
114 |
-
|
115 |
0x02,
|
116 |
0x00,
|
117 |
]);
|
118 |
-
const checksum =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
119 |
packet[7] = checksum;
|
120 |
|
121 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
-
|
133 |
}
|
134 |
} else {
|
135 |
-
|
|
|
|
|
|
|
|
|
|
|
136 |
}
|
137 |
-
}
|
138 |
-
|
|
|
|
|
|
|
|
|
139 |
}
|
140 |
} catch (error) {
|
141 |
-
|
|
|
142 |
}
|
143 |
|
144 |
-
//
|
145 |
-
await new Promise((resolve) => setTimeout(resolve,
|
146 |
}
|
147 |
|
148 |
return motorPositions;
|
149 |
}
|
150 |
|
151 |
/**
|
152 |
-
*
|
153 |
*/
|
154 |
-
async function
|
155 |
-
config
|
156 |
-
|
157 |
-
|
158 |
-
|
159 |
-
|
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 |
-
|
168 |
-
|
|
|
|
|
169 |
}
|
170 |
|
171 |
-
//
|
172 |
-
|
173 |
-
|
174 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
175 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
176 |
const rangeMins: { [motor: string]: number } = {};
|
177 |
const rangeMaxes: { [motor: string]: number } = {};
|
178 |
|
179 |
-
//
|
180 |
-
|
|
|
|
|
181 |
for (let i = 0; i < config.motorNames.length; i++) {
|
182 |
const motorName = config.motorNames[i];
|
183 |
-
const
|
184 |
-
rangeMins[motorName] = position
|
185 |
-
rangeMaxes[motorName] = position
|
186 |
}
|
187 |
|
188 |
-
//
|
189 |
-
|
190 |
-
const startTime = Date.now();
|
191 |
|
192 |
-
while (
|
193 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
194 |
|
195 |
-
|
196 |
-
|
197 |
-
|
|
|
|
|
|
|
198 |
|
199 |
-
|
200 |
-
rangeMins[motorName] = position;
|
201 |
}
|
202 |
-
|
203 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
204 |
}
|
|
|
|
|
205 |
}
|
206 |
|
207 |
-
|
|
|
208 |
}
|
209 |
|
210 |
-
|
211 |
-
|
212 |
-
|
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 |
-
*
|
222 |
*/
|
223 |
-
|
224 |
-
|
225 |
-
|
226 |
-
|
227 |
-
|
228 |
-
|
229 |
-
|
230 |
-
|
231 |
-
|
232 |
-
|
233 |
-
|
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 |
-
|
263 |
-
|
264 |
-
|
265 |
-
|
266 |
-
|
267 |
-
|
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 |
-
|
276 |
-
|
|
|
277 |
|
278 |
-
|
279 |
-
|
280 |
|
281 |
-
|
282 |
-
|
283 |
|
284 |
-
|
285 |
-
|
286 |
-
|
287 |
-
|
288 |
-
|
289 |
-
|
290 |
-
|
|
|
|
|
|
|
|
|
291 |
}
|
|
|
|
|
292 |
}
|
293 |
|
294 |
/**
|
295 |
-
*
|
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
|
300 |
-
|
301 |
-
|
302 |
-
|
|
|
|
|
|
|
|
|
303 |
}
|
304 |
|
305 |
-
|
306 |
-
|
307 |
-
throw new Error("Choose either a robot or a teleop.");
|
308 |
}
|
309 |
|
310 |
-
|
|
|
|
|
311 |
|
312 |
-
|
313 |
-
|
314 |
-
|
315 |
-
|
316 |
-
|
317 |
-
|
318 |
-
|
319 |
-
|
320 |
-
|
321 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
322 |
);
|
323 |
|
324 |
-
//
|
325 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
326 |
} catch (error) {
|
327 |
-
|
328 |
-
|
329 |
-
|
330 |
-
}`
|
331 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
332 |
}
|
333 |
}
|
334 |
|
@@ -356,49 +830,3 @@ function downloadCalibrationFile(calibrationData: any, deviceId: string): void {
|
|
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 |
-
}
|
|
|
1 |
/**
|
2 |
* Web calibration functionality using Web Serial API
|
3 |
+
* For browser environments - matches Node.js implementation exactly
|
4 |
*/
|
5 |
|
6 |
import type { CalibrateConfig } from "../node/robots/config.js";
|
7 |
|
8 |
/**
|
9 |
+
* Device-agnostic calibration configuration for web
|
10 |
+
* Mirrors the Node.js SO100CalibrationConfig exactly
|
11 |
+
*/
|
12 |
+
interface WebCalibrationConfig {
|
13 |
+
deviceType: "so100_follower" | "so100_leader";
|
14 |
+
port: WebSerialPortWrapper;
|
15 |
+
motorNames: string[];
|
16 |
+
motorIds: number[];
|
17 |
+
driveModes: number[];
|
18 |
+
calibModes: string[];
|
19 |
+
|
20 |
+
// Protocol-specific configuration (matches Node.js exactly)
|
21 |
+
protocol: {
|
22 |
+
resolution: number;
|
23 |
+
homingOffsetAddress: number;
|
24 |
+
homingOffsetLength: number;
|
25 |
+
presentPositionAddress: number;
|
26 |
+
presentPositionLength: number;
|
27 |
+
minPositionLimitAddress: number;
|
28 |
+
minPositionLimitLength: number;
|
29 |
+
maxPositionLimitAddress: number;
|
30 |
+
maxPositionLimitLength: number;
|
31 |
+
signMagnitudeBit: number;
|
32 |
+
};
|
33 |
+
|
34 |
+
limits: {
|
35 |
+
position_min: number[];
|
36 |
+
position_max: number[];
|
37 |
+
velocity_max: number[];
|
38 |
+
torque_max: number[];
|
39 |
+
};
|
40 |
+
}
|
41 |
+
|
42 |
+
/**
|
43 |
+
* Calibration results structure matching Python lerobot format exactly
|
44 |
+
*/
|
45 |
+
export interface WebCalibrationResults {
|
46 |
+
[motorName: string]: {
|
47 |
+
id: number;
|
48 |
+
drive_mode: number;
|
49 |
+
homing_offset: number;
|
50 |
+
range_min: number;
|
51 |
+
range_max: number;
|
52 |
+
};
|
53 |
+
}
|
54 |
+
|
55 |
+
/**
|
56 |
+
* STS3215 Protocol Configuration for web (matches Node.js exactly)
|
57 |
+
*/
|
58 |
+
const WEB_STS3215_PROTOCOL = {
|
59 |
+
resolution: 4096, // 12-bit resolution (0-4095)
|
60 |
+
homingOffsetAddress: 31, // Address for Homing_Offset register
|
61 |
+
homingOffsetLength: 2, // 2 bytes for Homing_Offset
|
62 |
+
presentPositionAddress: 56, // Address for Present_Position register
|
63 |
+
presentPositionLength: 2, // 2 bytes for Present_Position
|
64 |
+
minPositionLimitAddress: 9, // Address for Min_Position_Limit register
|
65 |
+
minPositionLimitLength: 2, // 2 bytes for Min_Position_Limit
|
66 |
+
maxPositionLimitAddress: 11, // Address for Max_Position_Limit register
|
67 |
+
maxPositionLimitLength: 2, // 2 bytes for Max_Position_Limit
|
68 |
+
signMagnitudeBit: 11, // Bit 11 is sign bit for Homing_Offset encoding
|
69 |
+
} as const;
|
70 |
+
|
71 |
+
/**
|
72 |
+
* Sign-magnitude encoding functions (matches Node.js exactly)
|
73 |
+
*/
|
74 |
+
function encodeSignMagnitude(value: number, signBitIndex: number): number {
|
75 |
+
const maxMagnitude = (1 << signBitIndex) - 1;
|
76 |
+
const magnitude = Math.abs(value);
|
77 |
+
|
78 |
+
if (magnitude > maxMagnitude) {
|
79 |
+
throw new Error(
|
80 |
+
`Magnitude ${magnitude} exceeds ${maxMagnitude} (max for signBitIndex=${signBitIndex})`
|
81 |
+
);
|
82 |
+
}
|
83 |
+
|
84 |
+
const directionBit = value < 0 ? 1 : 0;
|
85 |
+
return (directionBit << signBitIndex) | magnitude;
|
86 |
+
}
|
87 |
+
|
88 |
+
/**
|
89 |
+
* PROPER Web Serial Port wrapper following Chrome documentation exactly
|
90 |
+
* Direct write/read with immediate lock release - NO persistent connections
|
91 |
*/
|
92 |
class WebSerialPortWrapper {
|
93 |
private port: SerialPort;
|
|
|
|
|
94 |
|
95 |
constructor(port: SerialPort) {
|
96 |
this.port = port;
|
97 |
}
|
98 |
|
99 |
get isOpen(): boolean {
|
100 |
+
return (
|
101 |
+
this.port !== null &&
|
102 |
+
this.port.readable !== null &&
|
103 |
+
this.port.writable !== null
|
104 |
+
);
|
105 |
}
|
106 |
|
107 |
async initialize(): Promise<void> {
|
108 |
+
if (!this.port.readable || !this.port.writable) {
|
109 |
+
throw new Error("Port is not open for reading/writing");
|
|
|
|
|
|
|
|
|
110 |
}
|
111 |
}
|
112 |
|
113 |
+
/**
|
114 |
+
* Write data - EXACTLY like Chrome documentation
|
115 |
+
* Get writer, write, release lock immediately
|
116 |
+
*/
|
117 |
+
async write(data: Uint8Array): Promise<void> {
|
118 |
+
if (!this.port.writable) {
|
119 |
throw new Error("Port not open for writing");
|
120 |
}
|
121 |
+
|
122 |
+
// Write packet to motor
|
123 |
+
|
124 |
+
const writer = this.port.writable.getWriter();
|
125 |
+
try {
|
126 |
+
await writer.write(data);
|
127 |
+
} finally {
|
128 |
+
writer.releaseLock();
|
129 |
+
}
|
130 |
}
|
131 |
|
132 |
+
/**
|
133 |
+
* Read data - EXACTLY like Chrome documentation
|
134 |
+
* Get reader, read once, release lock immediately
|
135 |
+
*/
|
136 |
+
async read(timeout: number = 1000): Promise<Uint8Array> {
|
137 |
+
if (!this.port.readable) {
|
138 |
throw new Error("Port not open for reading");
|
139 |
}
|
140 |
|
141 |
+
const reader = this.port.readable.getReader();
|
|
|
|
|
|
|
142 |
|
143 |
+
try {
|
144 |
+
// Set up timeout
|
145 |
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
146 |
+
setTimeout(() => reject(new Error("Read timeout")), timeout);
|
147 |
+
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
148 |
|
149 |
+
// Race between read and timeout
|
150 |
+
const result = await Promise.race([reader.read(), timeoutPromise]);
|
151 |
+
|
152 |
+
const { value, done } = result;
|
153 |
+
|
154 |
+
if (done || !value) {
|
155 |
+
throw new Error("Read failed - port closed or no data");
|
156 |
+
}
|
157 |
+
|
158 |
+
const response = new Uint8Array(value);
|
159 |
+
return response;
|
160 |
+
} finally {
|
161 |
+
reader.releaseLock();
|
162 |
}
|
|
|
163 |
}
|
|
|
164 |
|
165 |
+
async close(): Promise<void> {
|
166 |
+
// Don't close the port itself - just wrapper cleanup
|
167 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
168 |
}
|
169 |
|
170 |
/**
|
171 |
+
* Read motor positions using device-agnostic configuration (exactly like Node.js)
|
172 |
*/
|
173 |
async function readMotorPositions(
|
174 |
+
config: WebCalibrationConfig
|
175 |
): Promise<number[]> {
|
176 |
const motorPositions: number[] = [];
|
|
|
177 |
|
178 |
+
// Reading motor positions
|
179 |
+
|
180 |
+
for (let i = 0; i < config.motorIds.length; i++) {
|
181 |
+
const motorId = config.motorIds[i];
|
182 |
+
const motorName = config.motorNames[i];
|
183 |
|
184 |
try {
|
185 |
+
// Create Read Position packet using configurable address
|
186 |
+
const packet = new Uint8Array([
|
187 |
0xff,
|
188 |
0xff,
|
189 |
motorId,
|
190 |
0x04,
|
191 |
0x02,
|
192 |
+
config.protocol.presentPositionAddress, // Configurable address
|
193 |
0x02,
|
194 |
0x00,
|
195 |
]);
|
196 |
+
const checksum =
|
197 |
+
~(
|
198 |
+
motorId +
|
199 |
+
0x04 +
|
200 |
+
0x02 +
|
201 |
+
config.protocol.presentPositionAddress +
|
202 |
+
0x02
|
203 |
+
) & 0xff;
|
204 |
packet[7] = checksum;
|
205 |
|
206 |
+
// Professional Feetech communication pattern (based on matthieuvigne/STS_servos)
|
207 |
+
let attempts = 0;
|
208 |
+
let success = false;
|
209 |
+
|
210 |
+
while (attempts < 3 && !success) {
|
211 |
+
attempts++;
|
212 |
+
|
213 |
+
// Clear any remaining data in buffer first (critical for Web Serial)
|
214 |
+
try {
|
215 |
+
await config.port.read(0); // Non-blocking read to clear buffer
|
216 |
+
} catch (e) {
|
217 |
+
// Expected - buffer was empty
|
218 |
+
}
|
219 |
+
|
220 |
+
// Write command with proper timing
|
221 |
+
await config.port.write(packet);
|
222 |
+
|
223 |
+
// Arduino library uses careful timing - Web Serial needs more
|
224 |
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
225 |
+
|
226 |
+
const response = await config.port.read(150);
|
227 |
|
|
|
|
|
228 |
if (response.length >= 7) {
|
229 |
const id = response[2];
|
230 |
const error = response[4];
|
231 |
+
|
232 |
if (id === motorId && error === 0) {
|
233 |
const position = response[5] | (response[6] << 8);
|
234 |
motorPositions.push(position);
|
235 |
+
success = true;
|
236 |
+
} else if (id === motorId && error !== 0) {
|
237 |
+
// Motor error, retry
|
238 |
} else {
|
239 |
+
// Wrong response ID, retry
|
240 |
}
|
241 |
} else {
|
242 |
+
// Short response, retry
|
243 |
+
}
|
244 |
+
|
245 |
+
// Professional timing between attempts (like Arduino libraries)
|
246 |
+
if (!success && attempts < 3) {
|
247 |
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
248 |
}
|
249 |
+
}
|
250 |
+
|
251 |
+
// If all attempts failed, use fallback
|
252 |
+
if (!success) {
|
253 |
+
const fallback = Math.floor((config.protocol.resolution - 1) / 2);
|
254 |
+
motorPositions.push(fallback);
|
255 |
}
|
256 |
} catch (error) {
|
257 |
+
const fallback = Math.floor((config.protocol.resolution - 1) / 2);
|
258 |
+
motorPositions.push(fallback);
|
259 |
}
|
260 |
|
261 |
+
// Professional inter-motor delay (based on Arduino STS_servos library)
|
262 |
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
263 |
}
|
264 |
|
265 |
return motorPositions;
|
266 |
}
|
267 |
|
268 |
/**
|
269 |
+
* Reset homing offsets to 0 for all motors (matches Node.js exactly)
|
270 |
*/
|
271 |
+
async function resetHomingOffsets(config: WebCalibrationConfig): Promise<void> {
|
272 |
+
for (let i = 0; i < config.motorIds.length; i++) {
|
273 |
+
const motorId = config.motorIds[i];
|
274 |
+
const motorName = config.motorNames[i];
|
275 |
+
|
276 |
+
try {
|
277 |
+
const homingOffsetValue = 0;
|
278 |
+
|
279 |
+
// Create Write Homing_Offset packet using configurable address
|
280 |
+
const packet = new Uint8Array([
|
281 |
+
0xff,
|
282 |
+
0xff, // Header
|
283 |
+
motorId, // Servo ID
|
284 |
+
0x05, // Length
|
285 |
+
0x03, // Instruction: WRITE_DATA
|
286 |
+
config.protocol.homingOffsetAddress, // Configurable address
|
287 |
+
homingOffsetValue & 0xff, // Data_L (low byte)
|
288 |
+
(homingOffsetValue >> 8) & 0xff, // Data_H (high byte)
|
289 |
+
0x00, // Checksum (will calculate)
|
290 |
+
]);
|
291 |
+
|
292 |
+
// Calculate checksum using configurable address
|
293 |
+
const checksum =
|
294 |
+
~(
|
295 |
+
motorId +
|
296 |
+
0x05 +
|
297 |
+
0x03 +
|
298 |
+
config.protocol.homingOffsetAddress +
|
299 |
+
(homingOffsetValue & 0xff) +
|
300 |
+
((homingOffsetValue >> 8) & 0xff)
|
301 |
+
) & 0xff;
|
302 |
+
packet[8] = checksum;
|
303 |
+
|
304 |
+
// Simple write then read like Node.js
|
305 |
+
await config.port.write(packet);
|
306 |
+
|
307 |
+
// Wait for response (silent unless error)
|
308 |
+
try {
|
309 |
+
await config.port.read(200);
|
310 |
+
} catch (error) {
|
311 |
+
// Silent - response not required for successful operation
|
312 |
+
}
|
313 |
+
} catch (error) {
|
314 |
+
throw new Error(
|
315 |
+
`Failed to reset homing offset for ${motorName}: ${
|
316 |
+
error instanceof Error ? error.message : error
|
317 |
+
}`
|
318 |
+
);
|
319 |
+
}
|
320 |
+
}
|
321 |
+
}
|
322 |
+
|
323 |
+
/**
|
324 |
+
* Write homing offsets to motor registers immediately (matches Node.js exactly)
|
325 |
+
*/
|
326 |
+
async function writeHomingOffsetsToMotors(
|
327 |
+
config: WebCalibrationConfig,
|
328 |
+
homingOffsets: { [motor: string]: number }
|
329 |
+
): Promise<void> {
|
330 |
+
for (let i = 0; i < config.motorIds.length; i++) {
|
331 |
+
const motorId = config.motorIds[i];
|
332 |
+
const motorName = config.motorNames[i];
|
333 |
+
const homingOffset = homingOffsets[motorName];
|
334 |
+
|
335 |
+
try {
|
336 |
+
// Encode using sign-magnitude format
|
337 |
+
const encodedOffset = encodeSignMagnitude(
|
338 |
+
homingOffset,
|
339 |
+
config.protocol.signMagnitudeBit
|
340 |
+
);
|
341 |
+
|
342 |
+
// Create Write Homing_Offset packet
|
343 |
+
const packet = new Uint8Array([
|
344 |
+
0xff,
|
345 |
+
0xff, // Header
|
346 |
+
motorId, // Servo ID
|
347 |
+
0x05, // Length
|
348 |
+
0x03, // Instruction: WRITE_DATA
|
349 |
+
config.protocol.homingOffsetAddress, // Homing_Offset address
|
350 |
+
encodedOffset & 0xff, // Data_L (low byte)
|
351 |
+
(encodedOffset >> 8) & 0xff, // Data_H (high byte)
|
352 |
+
0x00, // Checksum (will calculate)
|
353 |
+
]);
|
354 |
+
|
355 |
+
// Calculate checksum
|
356 |
+
const checksum =
|
357 |
+
~(
|
358 |
+
motorId +
|
359 |
+
0x05 +
|
360 |
+
0x03 +
|
361 |
+
config.protocol.homingOffsetAddress +
|
362 |
+
(encodedOffset & 0xff) +
|
363 |
+
((encodedOffset >> 8) & 0xff)
|
364 |
+
) & 0xff;
|
365 |
+
packet[8] = checksum;
|
366 |
+
|
367 |
+
// Simple write then read like Node.js
|
368 |
+
await config.port.write(packet);
|
369 |
+
|
370 |
+
// Wait for response (silent unless error)
|
371 |
+
try {
|
372 |
+
await config.port.read(200);
|
373 |
+
} catch (error) {
|
374 |
+
// Silent - response not required for successful operation
|
375 |
+
}
|
376 |
+
} catch (error) {
|
377 |
+
throw new Error(
|
378 |
+
`Failed to write homing offset for ${motorName}: ${
|
379 |
+
error instanceof Error ? error.message : error
|
380 |
+
}`
|
381 |
+
);
|
382 |
+
}
|
383 |
+
}
|
384 |
+
}
|
385 |
|
386 |
+
/**
|
387 |
+
* Record homing offsets with immediate writing (matches Node.js exactly)
|
388 |
+
*/
|
389 |
+
async function setHomingOffsets(
|
390 |
+
config: WebCalibrationConfig
|
391 |
+
): Promise<{ [motor: string]: number }> {
|
392 |
+
console.log("🏠 Setting homing offsets...");
|
393 |
+
|
394 |
+
// CRITICAL: Reset existing homing offsets to 0 first (matching Python)
|
395 |
+
await resetHomingOffsets(config);
|
396 |
+
|
397 |
+
// Wait a moment for reset to take effect
|
398 |
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
399 |
+
|
400 |
+
// Now read positions (which will be true physical positions)
|
401 |
const currentPositions = await readMotorPositions(config);
|
402 |
const homingOffsets: { [motor: string]: number } = {};
|
403 |
+
|
404 |
+
const halfTurn = Math.floor((config.protocol.resolution - 1) / 2);
|
405 |
+
|
406 |
for (let i = 0; i < config.motorNames.length; i++) {
|
407 |
const motorName = config.motorNames[i];
|
408 |
const position = currentPositions[i];
|
409 |
+
|
410 |
+
// Generic formula: pos - int((max_res - 1) / 2) using configurable resolution
|
411 |
+
const homingOffset = position - halfTurn;
|
412 |
+
homingOffsets[motorName] = homingOffset;
|
413 |
}
|
414 |
|
415 |
+
// CRITICAL: Write homing offsets to motors immediately (matching Python exactly)
|
416 |
+
await writeHomingOffsetsToMotors(config, homingOffsets);
|
417 |
+
|
418 |
+
return homingOffsets;
|
419 |
+
}
|
420 |
+
|
421 |
+
/**
|
422 |
+
* Generic function to write a 2-byte value to a motor register (matches Node.js exactly)
|
423 |
+
*/
|
424 |
+
async function writeMotorRegister(
|
425 |
+
config: WebCalibrationConfig,
|
426 |
+
motorId: number,
|
427 |
+
registerAddress: number,
|
428 |
+
value: number,
|
429 |
+
description: string
|
430 |
+
): Promise<void> {
|
431 |
+
// Create Write Register packet
|
432 |
+
const packet = new Uint8Array([
|
433 |
+
0xff,
|
434 |
+
0xff, // Header
|
435 |
+
motorId, // Servo ID
|
436 |
+
0x05, // Length
|
437 |
+
0x03, // Instruction: WRITE_DATA
|
438 |
+
registerAddress, // Register address
|
439 |
+
value & 0xff, // Data_L (low byte)
|
440 |
+
(value >> 8) & 0xff, // Data_H (high byte)
|
441 |
+
0x00, // Checksum (will calculate)
|
442 |
+
]);
|
443 |
+
|
444 |
+
// Calculate checksum
|
445 |
+
const checksum =
|
446 |
+
~(
|
447 |
+
motorId +
|
448 |
+
0x05 +
|
449 |
+
0x03 +
|
450 |
+
registerAddress +
|
451 |
+
(value & 0xff) +
|
452 |
+
((value >> 8) & 0xff)
|
453 |
+
) & 0xff;
|
454 |
+
packet[8] = checksum;
|
455 |
+
|
456 |
+
// Simple write then read like Node.js
|
457 |
+
await config.port.write(packet);
|
458 |
+
|
459 |
+
// Wait for response (silent unless error)
|
460 |
+
try {
|
461 |
+
await config.port.read(200);
|
462 |
+
} catch (error) {
|
463 |
+
// Silent - response not required for successful operation
|
464 |
+
}
|
465 |
+
}
|
466 |
+
|
467 |
+
/**
|
468 |
+
* Write hardware position limits to motors (matches Node.js exactly)
|
469 |
+
*/
|
470 |
+
async function writeHardwarePositionLimits(
|
471 |
+
config: WebCalibrationConfig,
|
472 |
+
rangeMins: { [motor: string]: number },
|
473 |
+
rangeMaxes: { [motor: string]: number }
|
474 |
+
): Promise<void> {
|
475 |
+
for (let i = 0; i < config.motorIds.length; i++) {
|
476 |
+
const motorId = config.motorIds[i];
|
477 |
+
const motorName = config.motorNames[i];
|
478 |
+
const minLimit = rangeMins[motorName];
|
479 |
+
const maxLimit = rangeMaxes[motorName];
|
480 |
+
|
481 |
+
try {
|
482 |
+
// Write Min_Position_Limit register
|
483 |
+
await writeMotorRegister(
|
484 |
+
config,
|
485 |
+
motorId,
|
486 |
+
config.protocol.minPositionLimitAddress,
|
487 |
+
minLimit,
|
488 |
+
`Min_Position_Limit for ${motorName}`
|
489 |
+
);
|
490 |
+
|
491 |
+
// Write Max_Position_Limit register
|
492 |
+
await writeMotorRegister(
|
493 |
+
config,
|
494 |
+
motorId,
|
495 |
+
config.protocol.maxPositionLimitAddress,
|
496 |
+
maxLimit,
|
497 |
+
`Max_Position_Limit for ${motorName}`
|
498 |
+
);
|
499 |
+
} catch (error) {
|
500 |
+
throw new Error(
|
501 |
+
`Failed to write position limits for ${motorName}: ${
|
502 |
+
error instanceof Error ? error.message : error
|
503 |
+
}`
|
504 |
+
);
|
505 |
+
}
|
506 |
+
}
|
507 |
+
}
|
508 |
|
509 |
+
/**
|
510 |
+
* Record ranges of motion with manual control (user decides when to stop)
|
511 |
+
*/
|
512 |
+
async function recordRangesOfMotion(
|
513 |
+
config: WebCalibrationConfig,
|
514 |
+
shouldStop: () => boolean,
|
515 |
+
onUpdate?: (
|
516 |
+
rangeMins: { [motor: string]: number },
|
517 |
+
rangeMaxes: { [motor: string]: number },
|
518 |
+
currentPositions: { [motor: string]: number }
|
519 |
+
) => void
|
520 |
+
): Promise<{
|
521 |
+
rangeMins: { [motor: string]: number };
|
522 |
+
rangeMaxes: { [motor: string]: number };
|
523 |
+
}> {
|
524 |
const rangeMins: { [motor: string]: number } = {};
|
525 |
const rangeMaxes: { [motor: string]: number } = {};
|
526 |
|
527 |
+
// Read actual current positions (matching Python exactly)
|
528 |
+
// After homing offsets are applied, these should be ~2047 (centered)
|
529 |
+
const startPositions = await readMotorPositions(config);
|
530 |
+
|
531 |
for (let i = 0; i < config.motorNames.length; i++) {
|
532 |
const motorName = config.motorNames[i];
|
533 |
+
const startPosition = startPositions[i];
|
534 |
+
rangeMins[motorName] = startPosition; // Use actual position, not hardcoded 2047
|
535 |
+
rangeMaxes[motorName] = startPosition; // Use actual position, not hardcoded 2047
|
536 |
}
|
537 |
|
538 |
+
// Manual recording using simple while loop like Node.js
|
539 |
+
let recordingCount = 0;
|
|
|
540 |
|
541 |
+
while (!shouldStop()) {
|
542 |
+
try {
|
543 |
+
const positions = await readMotorPositions(config);
|
544 |
+
recordingCount++;
|
545 |
+
|
546 |
+
for (let i = 0; i < config.motorNames.length; i++) {
|
547 |
+
const motorName = config.motorNames[i];
|
548 |
+
const position = positions[i];
|
549 |
+
const oldMin = rangeMins[motorName];
|
550 |
+
const oldMax = rangeMaxes[motorName];
|
551 |
|
552 |
+
if (position < rangeMins[motorName]) {
|
553 |
+
rangeMins[motorName] = position;
|
554 |
+
}
|
555 |
+
if (position > rangeMaxes[motorName]) {
|
556 |
+
rangeMaxes[motorName] = position;
|
557 |
+
}
|
558 |
|
559 |
+
// Track range expansions silently
|
|
|
560 |
}
|
561 |
+
|
562 |
+
// Continue recording silently
|
563 |
+
|
564 |
+
// Call update callback if provided (for live UI updates)
|
565 |
+
if (onUpdate) {
|
566 |
+
// Convert positions array to motor name map for UI
|
567 |
+
const currentPositions: { [motor: string]: number } = {};
|
568 |
+
for (let i = 0; i < config.motorNames.length; i++) {
|
569 |
+
currentPositions[config.motorNames[i]] = positions[i];
|
570 |
+
}
|
571 |
+
onUpdate(rangeMins, rangeMaxes, currentPositions);
|
572 |
}
|
573 |
+
} catch (error) {
|
574 |
+
console.warn("Error during range recording:", error);
|
575 |
}
|
576 |
|
577 |
+
// 20fps reading rate for stable Web Serial communication while maintaining responsive UI
|
578 |
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
579 |
}
|
580 |
|
581 |
+
// Range recording finished
|
582 |
+
|
583 |
+
return { rangeMins, rangeMaxes };
|
|
|
|
|
|
|
|
|
|
|
584 |
}
|
585 |
|
586 |
/**
|
587 |
+
* Interactive web calibration with manual control - user decides when to stop recording
|
588 |
*/
|
589 |
+
async function performWebCalibration(
|
590 |
+
config: WebCalibrationConfig,
|
591 |
+
shouldStopRecording: () => boolean,
|
592 |
+
onRangeUpdate?: (
|
593 |
+
rangeMins: { [motor: string]: number },
|
594 |
+
rangeMaxes: { [motor: string]: number },
|
595 |
+
currentPositions: { [motor: string]: number }
|
596 |
+
) => void
|
597 |
+
): Promise<WebCalibrationResults> {
|
598 |
+
// Step 1: Set homing position
|
599 |
+
const homingOffsets = await setHomingOffsets(config);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
600 |
|
601 |
+
// Step 2: Record ranges of motion with manual control
|
602 |
+
const { rangeMins, rangeMaxes } = await recordRangesOfMotion(
|
603 |
+
config,
|
604 |
+
shouldStopRecording,
|
605 |
+
onRangeUpdate
|
606 |
+
);
|
|
|
|
|
|
|
|
|
|
|
|
|
607 |
|
608 |
+
// Step 3: Set special range for wrist_roll (full turn motor)
|
609 |
+
rangeMins["wrist_roll"] = 0;
|
610 |
+
rangeMaxes["wrist_roll"] = 4095;
|
611 |
|
612 |
+
// Step 4: Write hardware position limits to motors (matching Python behavior)
|
613 |
+
await writeHardwarePositionLimits(config, rangeMins, rangeMaxes);
|
614 |
|
615 |
+
// Compile results in Python-compatible format (NOT array format!)
|
616 |
+
const results: WebCalibrationResults = {};
|
617 |
|
618 |
+
for (let i = 0; i < config.motorNames.length; i++) {
|
619 |
+
const motorName = config.motorNames[i];
|
620 |
+
const motorId = config.motorIds[i];
|
621 |
+
|
622 |
+
results[motorName] = {
|
623 |
+
id: motorId,
|
624 |
+
drive_mode: config.driveModes[i],
|
625 |
+
homing_offset: homingOffsets[motorName],
|
626 |
+
range_min: rangeMins[motorName],
|
627 |
+
range_max: rangeMaxes[motorName],
|
628 |
+
};
|
629 |
}
|
630 |
+
|
631 |
+
return results;
|
632 |
}
|
633 |
|
634 |
/**
|
635 |
+
* Step-by-step calibration for React components
|
|
|
|
|
636 |
*/
|
637 |
+
export class WebCalibrationController {
|
638 |
+
private config: WebCalibrationConfig;
|
639 |
+
private homingOffsets: { [motor: string]: number } | null = null;
|
640 |
+
private rangeMins: { [motor: string]: number } | null = null;
|
641 |
+
private rangeMaxes: { [motor: string]: number } | null = null;
|
642 |
+
|
643 |
+
constructor(config: WebCalibrationConfig) {
|
644 |
+
this.config = config;
|
645 |
}
|
646 |
|
647 |
+
async readMotorPositions(): Promise<number[]> {
|
648 |
+
return await readMotorPositions(this.config);
|
|
|
649 |
}
|
650 |
|
651 |
+
async performHomingStep(): Promise<void> {
|
652 |
+
this.homingOffsets = await setHomingOffsets(this.config);
|
653 |
+
}
|
654 |
|
655 |
+
async performRangeRecordingStep(
|
656 |
+
shouldStop: () => boolean,
|
657 |
+
onUpdate?: (
|
658 |
+
rangeMins: { [motor: string]: number },
|
659 |
+
rangeMaxes: { [motor: string]: number },
|
660 |
+
currentPositions: { [motor: string]: number }
|
661 |
+
) => void
|
662 |
+
): Promise<void> {
|
663 |
+
const { rangeMins, rangeMaxes } = await recordRangesOfMotion(
|
664 |
+
this.config,
|
665 |
+
shouldStop,
|
666 |
+
onUpdate
|
667 |
+
);
|
668 |
+
this.rangeMins = rangeMins;
|
669 |
+
this.rangeMaxes = rangeMaxes;
|
670 |
+
|
671 |
+
// Set special range for wrist_roll (full turn motor)
|
672 |
+
this.rangeMins["wrist_roll"] = 0;
|
673 |
+
this.rangeMaxes["wrist_roll"] = 4095;
|
674 |
+
}
|
675 |
+
|
676 |
+
async finishCalibration(): Promise<WebCalibrationResults> {
|
677 |
+
if (!this.homingOffsets || !this.rangeMins || !this.rangeMaxes) {
|
678 |
+
throw new Error("Must complete all calibration steps first");
|
679 |
+
}
|
680 |
+
|
681 |
+
// Write hardware position limits to motors (matching Python behavior)
|
682 |
+
await writeHardwarePositionLimits(
|
683 |
+
this.config,
|
684 |
+
this.rangeMins,
|
685 |
+
this.rangeMaxes
|
686 |
);
|
687 |
|
688 |
+
// Compile results in Python-compatible format (NOT array format!)
|
689 |
+
const results: WebCalibrationResults = {};
|
690 |
+
|
691 |
+
for (let i = 0; i < this.config.motorNames.length; i++) {
|
692 |
+
const motorName = this.config.motorNames[i];
|
693 |
+
const motorId = this.config.motorIds[i];
|
694 |
+
|
695 |
+
results[motorName] = {
|
696 |
+
id: motorId,
|
697 |
+
drive_mode: this.config.driveModes[i],
|
698 |
+
homing_offset: this.homingOffsets[motorName],
|
699 |
+
range_min: this.rangeMins[motorName],
|
700 |
+
range_max: this.rangeMaxes[motorName],
|
701 |
+
};
|
702 |
+
}
|
703 |
+
|
704 |
+
console.log("🎉 Calibration completed successfully!");
|
705 |
+
return results;
|
706 |
+
}
|
707 |
+
}
|
708 |
+
|
709 |
+
/**
|
710 |
+
* Create SO-100 web configuration (matches Node.js exactly)
|
711 |
+
*/
|
712 |
+
function createSO100WebConfig(
|
713 |
+
deviceType: "so100_follower" | "so100_leader",
|
714 |
+
port: WebSerialPortWrapper
|
715 |
+
): WebCalibrationConfig {
|
716 |
+
return {
|
717 |
+
deviceType,
|
718 |
+
port,
|
719 |
+
motorNames: [
|
720 |
+
"shoulder_pan",
|
721 |
+
"shoulder_lift",
|
722 |
+
"elbow_flex",
|
723 |
+
"wrist_flex",
|
724 |
+
"wrist_roll",
|
725 |
+
"gripper",
|
726 |
+
],
|
727 |
+
motorIds: [1, 2, 3, 4, 5, 6],
|
728 |
+
protocol: WEB_STS3215_PROTOCOL,
|
729 |
+
driveModes: [0, 0, 0, 0, 0, 0], // Python lerobot uses drive_mode=0 for all motors
|
730 |
+
calibModes: ["DEGREE", "DEGREE", "DEGREE", "DEGREE", "DEGREE", "LINEAR"],
|
731 |
+
limits: {
|
732 |
+
position_min: [-180, -90, -90, -90, -90, -90],
|
733 |
+
position_max: [180, 90, 90, 90, 90, 90],
|
734 |
+
velocity_max: [100, 100, 100, 100, 100, 100],
|
735 |
+
torque_max: [50, 50, 50, 50, 25, 25],
|
736 |
+
},
|
737 |
+
};
|
738 |
+
}
|
739 |
+
|
740 |
+
/**
|
741 |
+
* Create a calibration controller for step-by-step calibration in React components
|
742 |
+
*/
|
743 |
+
export async function createCalibrationController(
|
744 |
+
armType: "so100_follower" | "so100_leader",
|
745 |
+
connectedPort: SerialPort
|
746 |
+
): Promise<WebCalibrationController> {
|
747 |
+
// Create web serial port wrapper
|
748 |
+
const port = new WebSerialPortWrapper(connectedPort);
|
749 |
+
await port.initialize();
|
750 |
+
|
751 |
+
// Get device-agnostic calibration configuration
|
752 |
+
const config = createSO100WebConfig(armType, port);
|
753 |
+
|
754 |
+
return new WebCalibrationController(config);
|
755 |
+
}
|
756 |
+
|
757 |
+
/**
|
758 |
+
* Save calibration results to unified storage system
|
759 |
+
*/
|
760 |
+
export async function saveCalibrationResults(
|
761 |
+
calibrationResults: WebCalibrationResults,
|
762 |
+
armType: "so100_follower" | "so100_leader",
|
763 |
+
armId: string,
|
764 |
+
serialNumber: string,
|
765 |
+
recordingCount: number = 0
|
766 |
+
): Promise<void> {
|
767 |
+
// Prepare full calibration data
|
768 |
+
const fullCalibrationData = {
|
769 |
+
...calibrationResults,
|
770 |
+
device_type: armType,
|
771 |
+
device_id: armId,
|
772 |
+
calibrated_at: new Date().toISOString(),
|
773 |
+
platform: "web",
|
774 |
+
api: "Web Serial API",
|
775 |
+
};
|
776 |
+
|
777 |
+
const metadata = {
|
778 |
+
timestamp: new Date().toISOString(),
|
779 |
+
readCount: recordingCount,
|
780 |
+
};
|
781 |
+
|
782 |
+
// Try to save using unified storage system
|
783 |
+
try {
|
784 |
+
const { saveCalibrationData } = await import(
|
785 |
+
"../../demo/lib/unified-storage"
|
786 |
+
);
|
787 |
+
saveCalibrationData(serialNumber, fullCalibrationData, metadata);
|
788 |
+
console.log(
|
789 |
+
`✅ Calibration saved to unified storage: lerobotjs-${serialNumber}`
|
790 |
+
);
|
791 |
} catch (error) {
|
792 |
+
console.warn(
|
793 |
+
"Failed to save to unified storage, falling back to old format:",
|
794 |
+
error
|
|
|
795 |
);
|
796 |
+
|
797 |
+
// Fallback to old storage format for compatibility
|
798 |
+
const fullDataKey = `lerobot_calibration_${armType}_${armId}`;
|
799 |
+
localStorage.setItem(fullDataKey, JSON.stringify(fullCalibrationData));
|
800 |
+
|
801 |
+
const dashboardKey = `lerobot-calibration-${serialNumber}`;
|
802 |
+
localStorage.setItem(dashboardKey, JSON.stringify(metadata));
|
803 |
+
|
804 |
+
console.log(`📊 Dashboard data saved to: ${dashboardKey}`);
|
805 |
+
console.log(`🔧 Full calibration data saved to: ${fullDataKey}`);
|
806 |
}
|
807 |
}
|
808 |
|
|
|
830 |
export function isWebSerialSupported(): boolean {
|
831 |
return "serial" in navigator;
|
832 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/main.ts
DELETED
@@ -1,553 +0,0 @@
|
|
1 |
-
/**
|
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 |
-
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
vanilla.html
DELETED
@@ -1,32 +0,0 @@
|
|
1 |
-
<!DOCTYPE html>
|
2 |
-
<html lang="en">
|
3 |
-
<head>
|
4 |
-
<meta charset="UTF-8" />
|
5 |
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
6 |
-
<title>🤖 lerobot.js - Vanilla Demo</title>
|
7 |
-
<meta
|
8 |
-
name="description"
|
9 |
-
content="State-of-the-art AI for real-world robotics in JavaScript/TypeScript"
|
10 |
-
/>
|
11 |
-
<style>
|
12 |
-
/* Prevent flash of unstyled content */
|
13 |
-
body {
|
14 |
-
opacity: 0;
|
15 |
-
}
|
16 |
-
body.loaded {
|
17 |
-
opacity: 1;
|
18 |
-
transition: opacity 0.3s ease;
|
19 |
-
}
|
20 |
-
</style>
|
21 |
-
</head>
|
22 |
-
<body>
|
23 |
-
<div id="app"></div>
|
24 |
-
<script type="module" src="/src/main.ts"></script>
|
25 |
-
<script>
|
26 |
-
// Add loaded class when page is ready
|
27 |
-
window.addEventListener("load", () => {
|
28 |
-
document.body.classList.add("loaded");
|
29 |
-
});
|
30 |
-
</script>
|
31 |
-
</body>
|
32 |
-
</html>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|