NERDDISCO commited on
Commit
737c5f6
·
1 Parent(s): 2386c32

feat: calibration in web

Browse files
docs/conventions.md CHANGED
@@ -48,17 +48,35 @@
48
 
49
  ## Architecture Principles
50
 
51
- ### 1. Python lerobot Faithfulness (Primary Principle)
52
 
53
- **lerobot.js must maintain UX/API compatibility with Python lerobot**
 
 
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
- - **No "Improvements"**: Resist urge to add features/UX that Python version doesn't have
 
 
 
 
 
 
 
 
 
 
60
 
61
- > **Why?** Users are already trained on Python lerobot. Our goal is seamless migration to TypeScript, not learning a new tool.
 
 
 
 
 
 
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
- - **Port, Don't Innovate**: Direct ports are better than clever improvements
128
- - **User Expectations**: Maintain the exact experience Python users expect
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
- - **UX Matching**: Test that commands, outputs, and workflows match exactly
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
- ## Hardware Implementation Lessons
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
 
170
- ### Critical Hardware Compatibility
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
14
+ '@radix-ui/react-progress':
15
+ specifier: ^1.1.7
16
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
1682
+ dependencies:
1683
+ react: 18.3.1
1684
+ optionalDependencies:
1685
+ '@types/react': 18.3.23
1686
+
1687
1688
+ dependencies:
1689
+ react: 18.3.1
1690
+ optionalDependencies:
1691
+ '@types/react': 18.3.23
1692
+
1693
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
1703
1704
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
1716
+ dependencies:
1717
+ '@radix-ui/primitive': 1.1.2
1718
+ '@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected])
1719
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
1729
+ dependencies:
1730
+ react: 18.3.1
1731
+ optionalDependencies:
1732
+ '@types/react': 18.3.23
1733
+
1734
1735
+ dependencies:
1736
+ '@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected])
1737
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
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
1753
+ dependencies:
1754
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
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
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
1782
+ dependencies:
1783
+ '@radix-ui/react-context': 1.1.2(@types/[email protected])([email protected])
1784
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
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
1799
+ dependencies:
1800
+ react: 18.3.1
1801
+ optionalDependencies:
1802
+ '@types/react': 18.3.23
1803
+
1804
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
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
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
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
2105
+
2106
2107
 
2108
 
2185
 
2186
2187
 
2188
2189
+
2190
2191
  dependencies:
2192
  resolve-pkg-maps: 1.0.0
 
2389
 
2390
2391
 
2392
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
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
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
2603
+
2604
2605
  dependencies:
2606
  esbuild: 0.25.5
 
2618
  escalade: 3.2.0
2619
  picocolors: 1.1.1
2620
 
2621
2622
+ dependencies:
2623
+ react: 18.3.1
2624
+ tslib: 2.8.1
2625
+ optionalDependencies:
2626
+ '@types/react': 18.3.23
2627
+
2628
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, { useState, useEffect, useCallback, useRef } from "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 { calibrateWithPort } from "../../lerobot/web/calibrate";
 
 
 
 
 
 
12
  import type { ConnectedRobot } from "../types";
13
 
14
  interface CalibrationPanelProps {
@@ -24,275 +36,367 @@ interface MotorCalibrationData {
24
  range: number;
25
  }
26
 
27
- export function CalibrationPanel({ robot, onFinish }: CalibrationPanelProps) {
 
 
 
 
 
 
 
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
- const animationFrameRef = useRef<number>();
36
- const lastReadTime = useRef<number>(0);
37
- const isReading = useRef<boolean>(false);
 
 
 
 
 
 
 
 
 
38
 
39
- // Motor names matching Node CLI exactly
40
- const motorNames = [
41
- "waist",
42
- "shoulder",
43
- "elbow",
44
- "forearm_roll",
45
- "wrist_angle",
46
- "wrist_rotate",
47
- ];
 
 
 
 
48
 
49
- // Initialize motor data with center positions
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  const initializeMotorData = useCallback(() => {
51
  const initialData = motorNames.map((name) => ({
52
  name,
53
- current: 2047, // Center position for STS3215 (4095/2)
54
  min: 2047,
55
  max: 2047,
56
  range: 0,
57
  }));
58
  setMotorData(initialData);
59
- setReadCount(0);
60
- }, []);
61
 
62
- // Keep track of last known good positions to avoid glitches
63
- const lastKnownPositions = useRef<number[]>([
64
- 2047, 2047, 2047, 2047, 2047, 2047,
65
- ]);
 
66
 
67
- // Read actual motor positions with robust error handling
68
- const readMotorPositions = useCallback(async (): Promise<number[]> => {
69
- if (!robot.port || !robot.port.readable || !robot.port.writable) {
70
- throw new Error("Robot port not available for communication");
 
 
 
 
 
 
71
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
 
73
- const positions: number[] = [];
74
- const motorIds = [1, 2, 3, 4, 5, 6];
 
 
75
 
76
- // Get persistent reader/writer for this session
77
- const reader = robot.port.readable.getReader();
78
- const writer = robot.port.writable.getWriter();
 
 
79
 
80
- try {
81
- for (let index = 0; index < motorIds.length; index++) {
82
- const motorId = motorIds[index];
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
- const result = (await Promise.race([
114
- reader.read(),
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
- if (!success) {
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
- } finally {
158
- reader.releaseLock();
159
- writer.releaseLock();
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
- try {
177
- const positions = await readMotorPositions();
178
-
179
- // Always update since we're now keeping last known good positions
180
- // Only show warning if all motors are still at center position (no successful reads yet)
181
- const allAtCenter = positions.every((pos) => pos === 2047);
182
- if (allAtCenter && readCount === 0) {
183
- console.log("No motor data received yet - still trying to connect");
184
- setCalibrationStatus("Connecting to motors - please wait...");
185
  }
186
 
187
- setMotorData((prev) =>
188
- prev.map((motor, index) => {
189
- const current = positions[index];
190
- const min = Math.min(motor.min, current);
191
- const max = Math.max(motor.max, current);
192
- const range = max - min;
193
-
194
- return {
195
- ...motor,
196
- current,
197
- min,
198
- max,
199
- range,
200
- };
201
- })
202
- );
203
-
204
- setReadCount((prev) => prev + 1);
205
- console.log(`Real motor positions:`, positions);
206
- } catch (error) {
207
- console.warn("Failed to read motor positions:", error);
208
- setCalibrationStatus(
209
- `Error reading motors: ${
210
- error instanceof Error ? error.message : error
211
- }`
212
  );
213
- } finally {
214
- isReading.current = false;
215
- }
216
- }, [isCalibrating, readMotorPositions]);
217
 
218
- // Animation loop using RAF (requestAnimationFrame)
219
- const animationLoop = useCallback(() => {
220
- updateMotorData();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
 
222
- if (isCalibrating) {
223
- animationFrameRef.current = requestAnimationFrame(animationLoop);
224
- }
225
- }, [isCalibrating, updateMotorData]);
 
 
 
 
 
226
 
 
227
  useEffect(() => {
228
  initializeMotorData();
229
  }, [initializeMotorData]);
230
 
231
- useEffect(() => {
232
- if (isCalibrating) {
233
- animationFrameRef.current = requestAnimationFrame(animationLoop);
234
- } else {
235
- if (animationFrameRef.current) {
236
- cancelAnimationFrame(animationFrameRef.current);
237
- }
238
- }
 
 
 
 
 
 
 
 
 
 
 
 
239
 
240
- return () => {
241
- if (animationFrameRef.current) {
242
- cancelAnimationFrame(animationFrameRef.current);
243
- }
244
- };
245
- }, [isCalibrating, animationLoop]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- // Get current positions to use as starting point for min/max
259
- const currentPositions = await readMotorPositions();
260
-
261
- // Reset calibration data with current positions as both min and max
262
- const freshData = motorNames.map((name, index) => ({
263
- name,
264
- current: currentPositions[index],
265
- min: currentPositions[index], // Start with current position
266
- max: currentPositions[index], // Start with current position
267
- range: 0, // No range yet
268
- }));
269
-
270
- setMotorData(freshData);
271
- setReadCount(0);
272
- setIsCalibrating(true);
273
- setCalibrationComplete(false);
274
- setCalibrationStatus(
275
- "Recording ranges of motion - move all joints through their full range..."
276
- );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
277
  } catch (error) {
278
- setCalibrationStatus(
279
- `Error starting calibration: ${
280
- error instanceof Error ? error.message : error
281
- }`
282
- );
283
  }
284
  };
285
 
286
- // Generate calibration config JSON matching Node CLI format
287
  const generateConfigJSON = () => {
288
- const calibrationData = {
289
- homing_offset: motorData.map((motor) => motor.current - 2047), // Center offset
290
- drive_mode: [3, 3, 3, 3, 3, 3], // SO-100 standard drive mode
291
- start_pos: motorData.map((motor) => motor.min),
292
- end_pos: motorData.map((motor) => motor.max),
293
- calib_mode: ["middle", "middle", "middle", "middle", "middle", "middle"], // SO-100 standard
294
- motor_names: motorNames,
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
- : calibrationComplete
376
  ? "default"
377
  : "outline"
378
  }
379
  >
380
  {isCalibrating
381
  ? "Recording"
382
- : calibrationComplete
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">{calibrationStatus}</p>
393
  {isCalibrating && (
394
  <p className="text-xs text-blue-600 mt-1">
395
- Readings: {readCount} | Press "Finish Calibration" when done
 
396
  </p>
397
  )}
398
  </div>
399
 
400
  <div className="flex gap-2">
401
- {!isCalibrating && !calibrationComplete && (
402
- <Button onClick={startCalibration}>Start Calibration</Button>
 
 
403
  )}
404
 
405
- {isCalibrating && (
406
  <Button onClick={finishCalibration} variant="outline">
407
  Finish Calibration
408
  </Button>
409
  )}
410
 
411
- {calibrationComplete && (
 
 
 
 
 
 
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
- {calibrationComplete && (
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
- await port.open({ baudRate: 1000000 });
79
- isConnected = true;
 
 
 
 
 
 
 
 
 
 
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
- try {
228
- const savedRobotKey = `lerobot-robot-${serialNumber}`;
229
- const savedData = localStorage.getItem(savedRobotKey);
230
- if (savedData) {
231
- const parsed = JSON.parse(savedData);
232
- newRobot.robotType = parsed.robotType;
233
- newRobot.robotId = parsed.robotId;
234
- console.log("📋 Loaded saved robot configuration:", parsed);
 
 
 
 
 
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
- const portInfo = connectedRobots[index];
274
  if (portInfo.isConnected) {
275
  await portInfo.port.close();
276
  }
277
 
278
- const updatedRobots = connectedRobots.filter((_, i) => i !== index);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
  onConnectedRobotsChange(updatedRobots);
 
 
 
 
280
  } catch (error) {
281
  setError(
282
- error instanceof Error ? error.message : "Failed to disconnect port"
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 to localStorage using serial number
297
  if (updatedRobot.serialNumber) {
298
- try {
299
- const robotKey = `lerobot-robot-${updatedRobot.serialNumber}`;
300
- const robotData = {
301
- robotType,
302
- robotId,
303
- serialNumber: updatedRobot.serialNumber,
304
- lastUpdated: new Date().toISOString(),
305
- };
306
- localStorage.setItem(robotKey, JSON.stringify(robotData));
307
- console.log(
308
- "💾 Saved robot configuration for:",
309
- updatedRobot.serialNumber
310
- );
311
- } catch (error) {
312
- console.warn("Failed to save robot configuration:", error);
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 in localStorage using serial number
553
  const getCalibrationStatus = () => {
554
- if (!portInfo.serialNumber) return null;
 
 
555
 
556
- const calibrationKey = `lerobot-calibration-${portInfo.serialNumber}`;
557
  try {
558
- const saved = localStorage.getItem(calibrationKey);
559
- if (saved) {
560
- const calibrationData = JSON.parse(saved);
561
- return {
562
- timestamp: calibrationData.timestamp,
563
- readCount: calibrationData.readCount,
564
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
565
  }
566
  } catch (error) {
567
- console.warn("Failed to read calibration from localStorage:", error);
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
- * Web Serial Port wrapper to match Node.js SerialPort interface
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  */
11
  class WebSerialPortWrapper {
12
  private port: SerialPort;
13
- private reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
14
- private writer: WritableStreamDefaultWriter<Uint8Array> | null = null;
15
 
16
  constructor(port: SerialPort) {
17
  this.port = port;
18
  }
19
 
20
  get isOpen(): boolean {
21
- return this.port !== null && this.port.readable !== null;
 
 
 
 
22
  }
23
 
24
  async initialize(): Promise<void> {
25
- // Set up reader and writer for already opened port
26
- if (this.port.readable) {
27
- this.reader = this.port.readable.getReader();
28
- }
29
- if (this.port.writable) {
30
- this.writer = this.port.writable.getWriter();
31
  }
32
  }
33
 
34
- async write(data: Buffer): Promise<void> {
35
- if (!this.writer) {
 
 
 
 
36
  throw new Error("Port not open for writing");
37
  }
38
- await this.writer.write(new Uint8Array(data));
 
 
 
 
 
 
 
 
39
  }
40
 
41
- async read(timeout: number = 5000): Promise<Buffer> {
42
- if (!this.reader) {
 
 
 
 
43
  throw new Error("Port not open for reading");
44
  }
45
 
46
- return new Promise<Buffer>((resolve, reject) => {
47
- const timer = setTimeout(() => {
48
- reject(new Error("Read timeout"));
49
- }, timeout);
50
 
51
- this.reader!.read()
52
- .then(({ value, done }) => {
53
- clearTimeout(timer);
54
- if (done || !value) {
55
- reject(new Error("Read failed"));
56
- } else {
57
- resolve(Buffer.from(value));
58
- }
59
- })
60
- .catch(reject);
61
- });
62
- }
63
 
64
- async close(): Promise<void> {
65
- if (this.reader) {
66
- await this.reader.cancel();
67
- this.reader = null;
68
- }
69
- if (this.writer) {
70
- this.writer.releaseLock();
71
- this.writer = null;
 
 
 
 
 
72
  }
73
- // Don't close the port itself - let the UI manage that
74
  }
75
- }
76
 
77
- /**
78
- * SO-100 calibration configuration for web
79
- */
80
- interface WebSO100CalibrationConfig {
81
- deviceType: "so100_follower" | "so100_leader";
82
- port: WebSerialPortWrapper;
83
- motorNames: string[];
84
- driveModes: number[];
85
- calibModes: string[];
86
- limits: {
87
- position_min: number[];
88
- position_max: number[];
89
- velocity_max: number[];
90
- torque_max: number[];
91
- };
92
  }
93
 
94
  /**
95
- * Read motor positions using Web Serial API
96
  */
97
  async function readMotorPositions(
98
- config: WebSO100CalibrationConfig
99
  ): Promise<number[]> {
100
  const motorPositions: number[] = [];
101
- const motorIds = [1, 2, 3, 4, 5, 6]; // SO-100 uses servo IDs 1-6
102
 
103
- for (let i = 0; i < motorIds.length; i++) {
104
- const motorId = motorIds[i];
 
 
 
105
 
106
  try {
107
- // Create STS3215 Read Position packet
108
- const packet = Buffer.from([
109
  0xff,
110
  0xff,
111
  motorId,
112
  0x04,
113
  0x02,
114
- 0x38,
115
  0x02,
116
  0x00,
117
  ]);
118
- const checksum = ~(motorId + 0x04 + 0x02 + 0x38 + 0x02) & 0xff;
 
 
 
 
 
 
 
119
  packet[7] = checksum;
120
 
121
- await config.port.write(packet);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
 
123
- try {
124
- const response = await config.port.read(100);
125
  if (response.length >= 7) {
126
  const id = response[2];
127
  const error = response[4];
 
128
  if (id === motorId && error === 0) {
129
  const position = response[5] | (response[6] << 8);
130
  motorPositions.push(position);
 
 
 
131
  } else {
132
- motorPositions.push(2047); // Fallback to center
133
  }
134
  } else {
135
- motorPositions.push(2047);
 
 
 
 
 
136
  }
137
- } catch (readError) {
138
- motorPositions.push(2047);
 
 
 
 
139
  }
140
  } catch (error) {
141
- motorPositions.push(2047);
 
142
  }
143
 
144
- // Minimal delay between servo reads
145
- await new Promise((resolve) => setTimeout(resolve, 2));
146
  }
147
 
148
  return motorPositions;
149
  }
150
 
151
  /**
152
- * Interactive web calibration with live updates
153
  */
154
- async function performWebCalibration(
155
- config: WebSO100CalibrationConfig
156
- ): Promise<any> {
157
- // Step 1: Set homing position
158
- alert(
159
- `📍 STEP 1: Set Homing Position\n\nMove the SO-100 ${config.deviceType} to the MIDDLE of its range of motion and click OK...`
160
- );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  const currentPositions = await readMotorPositions(config);
163
  const homingOffsets: { [motor: string]: number } = {};
 
 
 
164
  for (let i = 0; i < config.motorNames.length; i++) {
165
  const motorName = config.motorNames[i];
166
  const position = currentPositions[i];
167
- const maxRes = 4095; // STS3215 resolution
168
- homingOffsets[motorName] = position - Math.floor(maxRes / 2);
 
 
169
  }
170
 
171
- // Step 2: Record ranges with simplified interface for web
172
- alert(
173
- `📏 STEP 2: Record Joint Ranges\n\nMove all joints through their full range of motion, then click OK when finished...`
174
- );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  const rangeMins: { [motor: string]: number } = {};
177
  const rangeMaxes: { [motor: string]: number } = {};
178
 
179
- // Initialize with current positions
180
- const initialPositions = await readMotorPositions(config);
 
 
181
  for (let i = 0; i < config.motorNames.length; i++) {
182
  const motorName = config.motorNames[i];
183
- const position = initialPositions[i];
184
- rangeMins[motorName] = position;
185
- rangeMaxes[motorName] = position;
186
  }
187
 
188
- // Record positions for a brief period
189
- const recordingDuration = 10000; // 10 seconds
190
- const startTime = Date.now();
191
 
192
- while (Date.now() - startTime < recordingDuration) {
193
- const positions = await readMotorPositions(config);
 
 
 
 
 
 
 
 
194
 
195
- for (let i = 0; i < config.motorNames.length; i++) {
196
- const motorName = config.motorNames[i];
197
- const position = positions[i];
 
 
 
198
 
199
- if (position < rangeMins[motorName]) {
200
- rangeMins[motorName] = position;
201
  }
202
- if (position > rangeMaxes[motorName]) {
203
- rangeMaxes[motorName] = position;
 
 
 
 
 
 
 
 
 
204
  }
 
 
205
  }
206
 
207
- await new Promise((resolve) => setTimeout(resolve, 100));
 
208
  }
209
 
210
- return {
211
- homing_offset: config.motorNames.map((name) => homingOffsets[name]),
212
- drive_mode: config.driveModes,
213
- start_pos: config.motorNames.map((name) => rangeMins[name]),
214
- end_pos: config.motorNames.map((name) => rangeMaxes[name]),
215
- calib_mode: config.calibModes,
216
- motor_names: config.motorNames,
217
- };
218
  }
219
 
220
  /**
221
- * Calibrate a device using an already connected port
222
  */
223
- export async function calibrateWithPort(
224
- armType: "so100_follower" | "so100_leader",
225
- armId: string,
226
- connectedPort: SerialPort
227
- ): Promise<void> {
228
- try {
229
- // Create web serial port wrapper
230
- const port = new WebSerialPortWrapper(connectedPort);
231
- await port.initialize();
232
-
233
- // Get SO-100 calibration configuration
234
- const so100Config: WebSO100CalibrationConfig = {
235
- deviceType: armType,
236
- port,
237
- motorNames: [
238
- "shoulder_pan",
239
- "shoulder_lift",
240
- "elbow_flex",
241
- "wrist_flex",
242
- "wrist_roll",
243
- "gripper",
244
- ],
245
- driveModes: [0, 0, 0, 0, 0, 0],
246
- calibModes: [
247
- "position",
248
- "position",
249
- "position",
250
- "position",
251
- "position",
252
- "position",
253
- ],
254
- limits: {
255
- position_min: [0, 0, 0, 0, 0, 0],
256
- position_max: [4095, 4095, 4095, 4095, 4095, 4095],
257
- velocity_max: [100, 100, 100, 100, 100, 100],
258
- torque_max: [50, 50, 50, 50, 25, 25],
259
- },
260
- };
261
 
262
- // Perform calibration
263
- const calibrationResults = await performWebCalibration(so100Config);
264
-
265
- // Save to browser storage and download
266
- const calibrationData = {
267
- ...calibrationResults,
268
- device_type: armType,
269
- device_id: armId,
270
- calibrated_at: new Date().toISOString(),
271
- platform: "web",
272
- api: "Web Serial API",
273
- };
274
 
275
- const storageKey = `lerobot_calibration_${armType}_${armId}`;
276
- localStorage.setItem(storageKey, JSON.stringify(calibrationData));
 
277
 
278
- // Download calibration file
279
- downloadCalibrationFile(calibrationData, armId);
280
 
281
- // Close wrapper (but not the underlying port)
282
- await port.close();
283
 
284
- console.log(`Configuration saved to browser storage and downloaded.`);
285
- } catch (error) {
286
- throw new Error(
287
- `Web calibration failed: ${
288
- error instanceof Error ? error.message : error
289
- }`
290
- );
 
 
 
 
291
  }
 
 
292
  }
293
 
294
  /**
295
- * Calibrate a device in the browser using Web Serial API
296
- * Must be called from user interaction (button click)
297
- * This version requests a new port - use calibrateWithPort for already connected ports
298
  */
299
- export async function calibrate(config: CalibrateConfig): Promise<void> {
300
- // Validate Web Serial API support
301
- if (!("serial" in navigator)) {
302
- throw new Error("Web Serial API not supported in this browser");
 
 
 
 
303
  }
304
 
305
- // Validate configuration
306
- if (Boolean(config.robot) === Boolean(config.teleop)) {
307
- throw new Error("Choose either a robot or a teleop.");
308
  }
309
 
310
- const deviceConfig = config.robot || config.teleop!;
 
 
311
 
312
- try {
313
- // Request a new port for this calibration
314
- const port = await navigator.serial.requestPort();
315
- await port.open({ baudRate: 1000000 });
316
-
317
- // Use the new port calibration function
318
- await calibrateWithPort(
319
- deviceConfig.type as "so100_follower" | "so100_leader",
320
- deviceConfig.id || deviceConfig.type,
321
- port
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
322
  );
323
 
324
- // Close the port we opened
325
- await port.close();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
326
  } catch (error) {
327
- throw new Error(
328
- `Web calibration failed: ${
329
- error instanceof Error ? error.message : error
330
- }`
331
  );
 
 
 
 
 
 
 
 
 
 
332
  }
333
  }
334
 
@@ -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>