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>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|