GitHub CI commited on
Commit ·
df3ef41
0
Parent(s):
Sync from leLab @ 39fcfee669f0b98e7a1fc4dde23025ea8cc7e5ce
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +40 -0
- .gitignore +48 -0
- Dockerfile +38 -0
- HTTPS_SETUP.md +219 -0
- LICENSE +202 -0
- README.md +20 -0
- __init__.py +0 -0
- components.json +20 -0
- eslint.config.js +29 -0
- index.html +19 -0
- package-lock.json +0 -0
- package.json +88 -0
- postcss.config.js +6 -0
- public/favicon.ico +0 -0
- public/lovable-uploads/5e648747-34b7-4d8f-93fd-4dbd00aeeefc.png +0 -0
- public/placeholder.svg +1 -0
- public/robots.txt +14 -0
- public/so-101-urdf/CMakeLists.txt +37 -0
- public/so-101-urdf/README.md +45 -0
- public/so-101-urdf/config/joint_names_so_arm_urdf.yaml +1 -0
- public/so-101-urdf/joints_properties.xml +12 -0
- public/so-101-urdf/meshes/base_motor_holder_so101_v1.stl +3 -0
- public/so-101-urdf/meshes/base_so101_v2.stl +3 -0
- public/so-101-urdf/meshes/motor_holder_so101_base_v1.stl +3 -0
- public/so-101-urdf/meshes/motor_holder_so101_wrist_v1.stl +3 -0
- public/so-101-urdf/meshes/moving_jaw_so101_v1.stl +3 -0
- public/so-101-urdf/meshes/rotation_pitch_so101_v1.stl +3 -0
- public/so-101-urdf/meshes/sts3215_03a_no_horn_v1.stl +3 -0
- public/so-101-urdf/meshes/sts3215_03a_v1.stl +3 -0
- public/so-101-urdf/meshes/under_arm_so101_v1.stl +3 -0
- public/so-101-urdf/meshes/upper_arm_so101_v1.stl +3 -0
- public/so-101-urdf/meshes/waveshare_mounting_plate_so101_v2.stl +3 -0
- public/so-101-urdf/meshes/wrist_roll_follower_so101_v1.stl +3 -0
- public/so-101-urdf/meshes/wrist_roll_pitch_so101_v2.stl +3 -0
- public/so-101-urdf/package.xml +26 -0
- public/so-101-urdf/urdf/so101_new_calib.urdf +435 -0
- src/.gitignore +1 -0
- src/App.tsx +65 -0
- src/components/Footer.tsx +68 -0
- src/components/Logo.tsx +19 -0
- src/components/SingleTabGuard.tsx +136 -0
- src/components/TeleopStopNotice.tsx +36 -0
- src/components/UpdateNotice.tsx +194 -0
- src/components/UrdfViewer.tsx +304 -0
- src/components/control/CameraFeed.tsx +46 -0
- src/components/control/CommandBar.tsx +81 -0
- src/components/control/MetricsPanel.tsx +190 -0
- src/components/control/TeleopCameraPanel.tsx +94 -0
- src/components/control/VisualizerPanel.tsx +52 -0
- src/components/jobs/CheckpointDropdown.tsx +58 -0
.gitattributes
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
*.stl filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
*.lockb filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
|
| 39 |
+
# Build output: store as plain binary (overrides LFS) so the wheel can ship real files
|
| 40 |
+
dist/** -filter -diff -merge
|
.gitignore
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dependencies
|
| 2 |
+
/node_modules
|
| 3 |
+
/.pnp
|
| 4 |
+
.pnp.js
|
| 5 |
+
|
| 6 |
+
# Testing
|
| 7 |
+
/coverage
|
| 8 |
+
|
| 9 |
+
# Production
|
| 10 |
+
/build
|
| 11 |
+
|
| 12 |
+
# Misc
|
| 13 |
+
.DS_Store
|
| 14 |
+
.env
|
| 15 |
+
.env.local
|
| 16 |
+
.env.development.local
|
| 17 |
+
.env.test.local
|
| 18 |
+
.env.production.local
|
| 19 |
+
.env*.local
|
| 20 |
+
|
| 21 |
+
# Debug logs
|
| 22 |
+
npm-debug.log*
|
| 23 |
+
yarn-debug.log*
|
| 24 |
+
yarn-error.log*
|
| 25 |
+
|
| 26 |
+
# IDE
|
| 27 |
+
.idea/
|
| 28 |
+
.vscode/
|
| 29 |
+
*.swp
|
| 30 |
+
*.swo
|
| 31 |
+
|
| 32 |
+
# TypeScript
|
| 33 |
+
*.tsbuildinfo
|
| 34 |
+
|
| 35 |
+
# Optional npm cache directory
|
| 36 |
+
.npm
|
| 37 |
+
|
| 38 |
+
# Optional eslint cache
|
| 39 |
+
.eslintcache
|
| 40 |
+
|
| 41 |
+
# Optional REPL history
|
| 42 |
+
.node_repl_history
|
| 43 |
+
|
| 44 |
+
# Output of 'npm pack'
|
| 45 |
+
*.tgz
|
| 46 |
+
|
| 47 |
+
# Yarn Integrity file
|
| 48 |
+
.yarn-integrity
|
Dockerfile
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Vite 8 requires Node ^20.19.0 or >=22.12.0.
|
| 2 |
+
FROM node:22-alpine
|
| 3 |
+
|
| 4 |
+
# Use the existing node user (usually UID 1000)
|
| 5 |
+
# Set up environment variables for the node user
|
| 6 |
+
ENV HOME=/home/node \
|
| 7 |
+
PATH=/home/node/.local/bin:$PATH
|
| 8 |
+
|
| 9 |
+
# Create and set up app directory owned by node user
|
| 10 |
+
# Go to user's home directory first to ensure it exists
|
| 11 |
+
WORKDIR $HOME
|
| 12 |
+
RUN mkdir -p $HOME/app && \
|
| 13 |
+
chown -R node:node $HOME/app && \
|
| 14 |
+
chmod -R 755 $HOME/app # Set initial permissions
|
| 15 |
+
WORKDIR $HOME/app
|
| 16 |
+
|
| 17 |
+
# Switch to the node user
|
| 18 |
+
USER node
|
| 19 |
+
|
| 20 |
+
# Copy package files (owned by node)
|
| 21 |
+
COPY --chown=node:node package*.json ./
|
| 22 |
+
|
| 23 |
+
# Install dependencies straight from the committed lockfile. `npm ci` honours
|
| 24 |
+
# the `resolved` URLs in package-lock.json and ignores the builder's configured
|
| 25 |
+
# registry, so the install can't be derailed by a slow/incomplete npm mirror.
|
| 26 |
+
RUN npm ci
|
| 27 |
+
|
| 28 |
+
# Copy the entire viewer directory (owned by node)
|
| 29 |
+
COPY --chown=node:node . .
|
| 30 |
+
|
| 31 |
+
# Build the application
|
| 32 |
+
RUN npm run build
|
| 33 |
+
|
| 34 |
+
# Expose port
|
| 35 |
+
EXPOSE 7860
|
| 36 |
+
|
| 37 |
+
# Start the application
|
| 38 |
+
CMD ["npm", "run", "preview", "--", "--port", "7860", "--host"]
|
HTTPS_SETUP.md
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# HTTPS Setup for Local Development
|
| 2 |
+
|
| 3 |
+
This guide explains how to enable HTTPS for the frontend development server to ensure camera access works properly from mobile devices.
|
| 4 |
+
|
| 5 |
+
## Why HTTPS is Required
|
| 6 |
+
|
| 7 |
+
Modern browsers require HTTPS for accessing sensitive APIs like `getUserMedia()` (camera access), especially when:
|
| 8 |
+
|
| 9 |
+
- Accessing from a different device (e.g., phone connecting to laptop's dev server)
|
| 10 |
+
- The origin is not `localhost` or `127.0.0.1`
|
| 11 |
+
|
| 12 |
+
Without HTTPS, camera access will be blocked by the browser's security policies.
|
| 13 |
+
|
| 14 |
+
## Automatic Setup
|
| 15 |
+
|
| 16 |
+
We've already set up HTTPS for you! The certificates are generated and the development server is configured to use them.
|
| 17 |
+
|
| 18 |
+
### Quick Start
|
| 19 |
+
|
| 20 |
+
1. **Start the development server:**
|
| 21 |
+
|
| 22 |
+
```bash
|
| 23 |
+
npm run dev
|
| 24 |
+
```
|
| 25 |
+
|
| 26 |
+
2. **Access from your computer:**
|
| 27 |
+
|
| 28 |
+
- `https://localhost:8080`
|
| 29 |
+
- `https://127.0.0.1:8080`
|
| 30 |
+
|
| 31 |
+
3. **Access from your phone:**
|
| 32 |
+
- `https://192.168.1.103:8080` (or your current local IP)
|
| 33 |
+
|
| 34 |
+
## Manual Setup (if needed)
|
| 35 |
+
|
| 36 |
+
If you need to regenerate certificates or set up on a new machine:
|
| 37 |
+
|
| 38 |
+
```bash
|
| 39 |
+
# Run our setup script
|
| 40 |
+
./scripts/setup-https.sh
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
Or manually:
|
| 44 |
+
|
| 45 |
+
```bash
|
| 46 |
+
# Install mkcert
|
| 47 |
+
brew install mkcert # macOS
|
| 48 |
+
# For other platforms: https://github.com/FiloSottile/mkcert#installation
|
| 49 |
+
|
| 50 |
+
# Install local CA
|
| 51 |
+
mkcert -install
|
| 52 |
+
|
| 53 |
+
# Generate certificates
|
| 54 |
+
mkdir -p certs
|
| 55 |
+
cd certs
|
| 56 |
+
mkcert localhost 127.0.0.1 $(ipconfig getifaddr en0) ::1
|
| 57 |
+
cd ..
|
| 58 |
+
|
| 59 |
+
# Start development server
|
| 60 |
+
npm run dev
|
| 61 |
+
```
|
| 62 |
+
|
| 63 |
+
## Certificate Details
|
| 64 |
+
|
| 65 |
+
- **Location:** `./certs/`
|
| 66 |
+
- **Files:**
|
| 67 |
+
- `localhost+3.pem` (certificate)
|
| 68 |
+
- `localhost+3-key.pem` (private key)
|
| 69 |
+
- **Validity:** 3 months
|
| 70 |
+
- **Domains:** localhost, 127.0.0.1, your local IP, IPv6 localhost
|
| 71 |
+
|
| 72 |
+
## Mobile Device Setup
|
| 73 |
+
|
| 74 |
+
### For Phone Access:
|
| 75 |
+
|
| 76 |
+
1. **Ensure same WiFi network:** Your phone and development machine must be on the same WiFi network
|
| 77 |
+
|
| 78 |
+
2. **Find your local IP:**
|
| 79 |
+
|
| 80 |
+
```bash
|
| 81 |
+
ipconfig getifaddr en0 # macOS
|
| 82 |
+
# or check the console output when starting the dev server
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
3. **Access from phone:**
|
| 86 |
+
|
| 87 |
+
- Open Safari (iOS) or Chrome (Android)
|
| 88 |
+
- Navigate to `https://YOUR_LOCAL_IP:8080`
|
| 89 |
+
- You may see a security warning - this is normal for local certificates
|
| 90 |
+
|
| 91 |
+
4. **Accept the certificate:**
|
| 92 |
+
|
| 93 |
+
- **iOS Safari:** Tap "Advanced" → "Proceed to website"
|
| 94 |
+
- **Android Chrome:** Tap "Advanced" → "Proceed to site (unsafe)"
|
| 95 |
+
|
| 96 |
+
5. **Test camera access:**
|
| 97 |
+
- Go to the recording page OR use our camera test page: `https://YOUR_LOCAL_IP:8080/test-camera.html`
|
| 98 |
+
- Try to add a camera or click "Start Camera"
|
| 99 |
+
- The browser should prompt for camera permission
|
| 100 |
+
- Grant permission to test `getUserMedia()` functionality
|
| 101 |
+
|
| 102 |
+
## Troubleshooting
|
| 103 |
+
|
| 104 |
+
### Certificate Issues
|
| 105 |
+
|
| 106 |
+
If you see certificate errors:
|
| 107 |
+
|
| 108 |
+
```bash
|
| 109 |
+
# Regenerate certificates
|
| 110 |
+
rm -rf certs/
|
| 111 |
+
./scripts/setup-https.sh
|
| 112 |
+
```
|
| 113 |
+
|
| 114 |
+
### IP Address Changes
|
| 115 |
+
|
| 116 |
+
If your local IP changes (common with DHCP):
|
| 117 |
+
|
| 118 |
+
```bash
|
| 119 |
+
# Check current IP
|
| 120 |
+
ipconfig getifaddr en0
|
| 121 |
+
|
| 122 |
+
# Regenerate certificates with new IP
|
| 123 |
+
rm -rf certs/
|
| 124 |
+
./scripts/setup-https.sh
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
### Port Already in Use
|
| 128 |
+
|
| 129 |
+
If port 8080 is busy:
|
| 130 |
+
|
| 131 |
+
```bash
|
| 132 |
+
# Check what's using the port
|
| 133 |
+
lsof -ti:8080
|
| 134 |
+
|
| 135 |
+
# Kill the process if needed
|
| 136 |
+
kill -9 $(lsof -ti:8080)
|
| 137 |
+
```
|
| 138 |
+
|
| 139 |
+
### Browser Cache Issues
|
| 140 |
+
|
| 141 |
+
If you're having issues after certificate changes:
|
| 142 |
+
|
| 143 |
+
1. Clear browser cache and cookies for localhost
|
| 144 |
+
2. Restart the browser
|
| 145 |
+
3. Try incognito/private mode
|
| 146 |
+
|
| 147 |
+
## Network Address Detection
|
| 148 |
+
|
| 149 |
+
The app automatically detects the appropriate URL for phone access:
|
| 150 |
+
|
| 151 |
+
- **Localhost access:** Automatically converts to network IP for QR codes
|
| 152 |
+
- **Network access:** Uses current URL as-is
|
| 153 |
+
- **Domain access:** Works with existing SSL certificates
|
| 154 |
+
|
| 155 |
+
You can see the detected address in:
|
| 156 |
+
|
| 157 |
+
- Browser console logs
|
| 158 |
+
- Camera configuration UI (blue info box)
|
| 159 |
+
|
| 160 |
+
## Security Notes
|
| 161 |
+
|
| 162 |
+
- These certificates are only trusted on your local machine
|
| 163 |
+
- They're perfect for development but should never be used in production
|
| 164 |
+
- The private key is stored locally and should not be shared
|
| 165 |
+
- Certificates expire after 3 months for security
|
| 166 |
+
|
| 167 |
+
## Production Deployment
|
| 168 |
+
|
| 169 |
+
For production:
|
| 170 |
+
|
| 171 |
+
- Use a proper SSL certificate from a trusted CA
|
| 172 |
+
- Configure your reverse proxy (nginx, Apache) or hosting platform
|
| 173 |
+
- Ensure HTTPS is enforced across your entire application
|
| 174 |
+
|
| 175 |
+
## Verification
|
| 176 |
+
|
| 177 |
+
To verify HTTPS is working:
|
| 178 |
+
|
| 179 |
+
1. **Check the URL bar:** Should show `https://` with a lock icon
|
| 180 |
+
2. **Check console:** Network address detection should show HTTPS URLs
|
| 181 |
+
3. **Test camera access:** `getUserMedia()` should work without security errors
|
| 182 |
+
4. **Check from phone:** Camera permissions should be available
|
| 183 |
+
|
| 184 |
+
### Quick Test Page
|
| 185 |
+
|
| 186 |
+
We've included a dedicated camera test page for easy verification:
|
| 187 |
+
|
| 188 |
+
**Desktop:** `https://localhost:8080/test-camera.html`
|
| 189 |
+
**Phone:** `https://192.168.1.103:8080/test-camera.html`
|
| 190 |
+
|
| 191 |
+
This page will:
|
| 192 |
+
|
| 193 |
+
- ✅ Verify HTTPS and secure context
|
| 194 |
+
- ✅ Test camera permissions and `getUserMedia()`
|
| 195 |
+
- ✅ Display real-time video stream
|
| 196 |
+
- ✅ Show detailed connection information
|
| 197 |
+
- ✅ Provide troubleshooting guidance
|
| 198 |
+
|
| 199 |
+
### Expected Results
|
| 200 |
+
|
| 201 |
+
**✅ Success indicators:**
|
| 202 |
+
|
| 203 |
+
- Green protocol check: "Camera access should work!"
|
| 204 |
+
- Camera permission prompt appears
|
| 205 |
+
- Video stream displays without errors
|
| 206 |
+
- No console security warnings
|
| 207 |
+
|
| 208 |
+
**❌ Failure indicators:**
|
| 209 |
+
|
| 210 |
+
- Red protocol check: "Camera access may be blocked"
|
| 211 |
+
- `NotAllowedError` or `NotSecureError` in console
|
| 212 |
+
- No camera permission prompt
|
| 213 |
+
- "This site is not secure" warnings
|
| 214 |
+
|
| 215 |
+
## Additional Resources
|
| 216 |
+
|
| 217 |
+
- [mkcert GitHub Repository](https://github.com/FiloSottile/mkcert)
|
| 218 |
+
- [MDN: getUserMedia()](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia)
|
| 219 |
+
- [Chrome Developer: HTTPS for Localhost](https://web.dev/how-to-use-local-https/)
|
LICENSE
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
Apache License
|
| 3 |
+
Version 2.0, January 2004
|
| 4 |
+
http://www.apache.org/licenses/
|
| 5 |
+
|
| 6 |
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
| 7 |
+
|
| 8 |
+
1. Definitions.
|
| 9 |
+
|
| 10 |
+
"License" shall mean the terms and conditions for use, reproduction,
|
| 11 |
+
and distribution as defined by Sections 1 through 9 of this document.
|
| 12 |
+
|
| 13 |
+
"Licensor" shall mean the copyright owner or entity authorized by
|
| 14 |
+
the copyright owner that is granting the License.
|
| 15 |
+
|
| 16 |
+
"Legal Entity" shall mean the union of the acting entity and all
|
| 17 |
+
other entities that control, are controlled by, or are under common
|
| 18 |
+
control with that entity. For the purposes of this definition,
|
| 19 |
+
"control" means (i) the power, direct or indirect, to cause the
|
| 20 |
+
direction or management of such entity, whether by contract or
|
| 21 |
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
| 22 |
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
| 23 |
+
|
| 24 |
+
"You" (or "Your") shall mean an individual or Legal Entity
|
| 25 |
+
exercising permissions granted by this License.
|
| 26 |
+
|
| 27 |
+
"Source" form shall mean the preferred form for making modifications,
|
| 28 |
+
including but not limited to software source code, documentation
|
| 29 |
+
source, and configuration files.
|
| 30 |
+
|
| 31 |
+
"Object" form shall mean any form resulting from mechanical
|
| 32 |
+
transformation or translation of a Source form, including but
|
| 33 |
+
not limited to compiled object code, generated documentation,
|
| 34 |
+
and conversions to other media types.
|
| 35 |
+
|
| 36 |
+
"Work" shall mean the work of authorship, whether in Source or
|
| 37 |
+
Object form, made available under the License, as indicated by a
|
| 38 |
+
copyright notice that is included in or attached to the work
|
| 39 |
+
(an example is provided in the Appendix below).
|
| 40 |
+
|
| 41 |
+
"Derivative Works" shall mean any work, whether in Source or Object
|
| 42 |
+
form, that is based on (or derived from) the Work and for which the
|
| 43 |
+
editorial revisions, annotations, elaborations, or other modifications
|
| 44 |
+
represent, as a whole, an original work of authorship. For the purposes
|
| 45 |
+
of this License, Derivative Works shall not include works that remain
|
| 46 |
+
separable from, or merely link (or bind by name) to the interfaces of,
|
| 47 |
+
the Work and Derivative Works thereof.
|
| 48 |
+
|
| 49 |
+
"Contribution" shall mean any work of authorship, including
|
| 50 |
+
the original version of the Work and any modifications or additions
|
| 51 |
+
to that Work or Derivative Works thereof, that is intentionally
|
| 52 |
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
| 53 |
+
or by an individual or Legal Entity authorized to submit on behalf of
|
| 54 |
+
the copyright owner. For the purposes of this definition, "submitted"
|
| 55 |
+
means any form of electronic, verbal, or written communication sent
|
| 56 |
+
to the Licensor or its representatives, including but not limited to
|
| 57 |
+
communication on electronic mailing lists, source code control systems,
|
| 58 |
+
and issue tracking systems that are managed by, or on behalf of, the
|
| 59 |
+
Licensor for the purpose of discussing and improving the Work, but
|
| 60 |
+
excluding communication that is conspicuously marked or otherwise
|
| 61 |
+
designated in writing by the copyright owner as "Not a Contribution."
|
| 62 |
+
|
| 63 |
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
| 64 |
+
on behalf of whom a Contribution has been received by Licensor and
|
| 65 |
+
subsequently incorporated within the Work.
|
| 66 |
+
|
| 67 |
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
| 68 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 69 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 70 |
+
copyright license to reproduce, prepare Derivative Works of,
|
| 71 |
+
publicly display, publicly perform, sublicense, and distribute the
|
| 72 |
+
Work and such Derivative Works in Source or Object form.
|
| 73 |
+
|
| 74 |
+
3. Grant of Patent License. Subject to the terms and conditions of
|
| 75 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 76 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 77 |
+
(except as stated in this section) patent license to make, have made,
|
| 78 |
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
| 79 |
+
where such license applies only to those patent claims licensable
|
| 80 |
+
by such Contributor that are necessarily infringed by their
|
| 81 |
+
Contribution(s) alone or by combination of their Contribution(s)
|
| 82 |
+
with the Work to which such Contribution(s) was submitted. If You
|
| 83 |
+
institute patent litigation against any entity (including a
|
| 84 |
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
| 85 |
+
or a Contribution incorporated within the Work constitutes direct
|
| 86 |
+
or contributory patent infringement, then any patent licenses
|
| 87 |
+
granted to You under this License for that Work shall terminate
|
| 88 |
+
as of the date such litigation is filed.
|
| 89 |
+
|
| 90 |
+
4. Redistribution. You may reproduce and distribute copies of the
|
| 91 |
+
Work or Derivative Works thereof in any medium, with or without
|
| 92 |
+
modifications, and in Source or Object form, provided that You
|
| 93 |
+
meet the following conditions:
|
| 94 |
+
|
| 95 |
+
(a) You must give any other recipients of the Work or
|
| 96 |
+
Derivative Works a copy of this License; and
|
| 97 |
+
|
| 98 |
+
(b) You must cause any modified files to carry prominent notices
|
| 99 |
+
stating that You changed the files; and
|
| 100 |
+
|
| 101 |
+
(c) You must retain, in the Source form of any Derivative Works
|
| 102 |
+
that You distribute, all copyright, patent, trademark, and
|
| 103 |
+
attribution notices from the Source form of the Work,
|
| 104 |
+
excluding those notices that do not pertain to any part of
|
| 105 |
+
the Derivative Works; and
|
| 106 |
+
|
| 107 |
+
(d) If the Work includes a "NOTICE" text file as part of its
|
| 108 |
+
distribution, then any Derivative Works that You distribute must
|
| 109 |
+
include a readable copy of the attribution notices contained
|
| 110 |
+
within such NOTICE file, excluding those notices that do not
|
| 111 |
+
pertain to any part of the Derivative Works, in at least one
|
| 112 |
+
of the following places: within a NOTICE text file distributed
|
| 113 |
+
as part of the Derivative Works; within the Source form or
|
| 114 |
+
documentation, if provided along with the Derivative Works; or,
|
| 115 |
+
within a display generated by the Derivative Works, if and
|
| 116 |
+
wherever such third-party notices normally appear. The contents
|
| 117 |
+
of the NOTICE file are for informational purposes only and
|
| 118 |
+
do not modify the License. You may add Your own attribution
|
| 119 |
+
notices within Derivative Works that You distribute, alongside
|
| 120 |
+
or as an addendum to the NOTICE text from the Work, provided
|
| 121 |
+
that such additional attribution notices cannot be construed
|
| 122 |
+
as modifying the License.
|
| 123 |
+
|
| 124 |
+
You may add Your own copyright statement to Your modifications and
|
| 125 |
+
may provide additional or different license terms and conditions
|
| 126 |
+
for use, reproduction, or distribution of Your modifications, or
|
| 127 |
+
for any such Derivative Works as a whole, provided Your use,
|
| 128 |
+
reproduction, and distribution of the Work otherwise complies with
|
| 129 |
+
the conditions stated in this License.
|
| 130 |
+
|
| 131 |
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
| 132 |
+
any Contribution intentionally submitted for inclusion in the Work
|
| 133 |
+
by You to the Licensor shall be under the terms and conditions of
|
| 134 |
+
this License, without any additional terms or conditions.
|
| 135 |
+
Notwithstanding the above, nothing herein shall supersede or modify
|
| 136 |
+
the terms of any separate license agreement you may have executed
|
| 137 |
+
with Licensor regarding such Contributions.
|
| 138 |
+
|
| 139 |
+
6. Trademarks. This License does not grant permission to use the trade
|
| 140 |
+
names, trademarks, service marks, or product names of the Licensor,
|
| 141 |
+
except as required for reasonable and customary use in describing the
|
| 142 |
+
origin of the Work and reproducing the content of the NOTICE file.
|
| 143 |
+
|
| 144 |
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
| 145 |
+
agreed to in writing, Licensor provides the Work (and each
|
| 146 |
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
| 147 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
| 148 |
+
implied, including, without limitation, any warranties or conditions
|
| 149 |
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
| 150 |
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
| 151 |
+
appropriateness of using or redistributing the Work and assume any
|
| 152 |
+
risks associated with Your exercise of permissions under this License.
|
| 153 |
+
|
| 154 |
+
8. Limitation of Liability. In no event and under no legal theory,
|
| 155 |
+
whether in tort (including negligence), contract, or otherwise,
|
| 156 |
+
unless required by applicable law (such as deliberate and grossly
|
| 157 |
+
negligent acts) or agreed to in writing, shall any Contributor be
|
| 158 |
+
liable to You for damages, including any direct, indirect, special,
|
| 159 |
+
incidental, or consequential damages of any character arising as a
|
| 160 |
+
result of this License or out of the use or inability to use the
|
| 161 |
+
Work (including but not limited to damages for loss of goodwill,
|
| 162 |
+
work stoppage, computer failure or malfunction, or any and all
|
| 163 |
+
other commercial damages or losses), even if such Contributor
|
| 164 |
+
has been advised of the possibility of such damages.
|
| 165 |
+
|
| 166 |
+
9. Accepting Warranty or Additional Liability. While redistributing
|
| 167 |
+
the Work or Derivative Works thereof, You may choose to offer,
|
| 168 |
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
| 169 |
+
or other liability obligations and/or rights consistent with this
|
| 170 |
+
License. However, in accepting such obligations, You may act only
|
| 171 |
+
on Your own behalf and on Your sole responsibility, not on behalf
|
| 172 |
+
of any other Contributor, and only if You agree to indemnify,
|
| 173 |
+
defend, and hold each Contributor harmless for any liability
|
| 174 |
+
incurred by, or claims asserted against, such Contributor by reason
|
| 175 |
+
of your accepting any such warranty or additional liability.
|
| 176 |
+
|
| 177 |
+
END OF TERMS AND CONDITIONS
|
| 178 |
+
|
| 179 |
+
APPENDIX: How to apply the Apache License to your work.
|
| 180 |
+
|
| 181 |
+
To apply the Apache License to your work, attach the following
|
| 182 |
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
| 183 |
+
replaced with your own identifying information. (Don't include
|
| 184 |
+
the brackets!) The text should be enclosed in the appropriate
|
| 185 |
+
comment syntax for the file format. We also recommend that a
|
| 186 |
+
file or class name and description of purpose be included on the
|
| 187 |
+
same "printed page" as the copyright notice for easier
|
| 188 |
+
identification within third-party archives.
|
| 189 |
+
|
| 190 |
+
Copyright [yyyy] [name of copyright owner]
|
| 191 |
+
|
| 192 |
+
Licensed under the Apache License, Version 2.0 (the "License");
|
| 193 |
+
you may not use this file except in compliance with the License.
|
| 194 |
+
You may obtain a copy of the License at
|
| 195 |
+
|
| 196 |
+
http://www.apache.org/licenses/LICENSE-2.0
|
| 197 |
+
|
| 198 |
+
Unless required by applicable law or agreed to in writing, software
|
| 199 |
+
distributed under the License is distributed on an "AS IS" BASIS,
|
| 200 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 201 |
+
See the License for the specific language governing permissions and
|
| 202 |
+
limitations under the License.
|
README.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: LeLab
|
| 3 |
+
emoji: ⚡
|
| 4 |
+
colorFrom: yellow
|
| 5 |
+
colorTo: red
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
short_description: Simple Interface to use LeRobot
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
<img width="1283" alt="Screenshot 2025-06-19 at 00 23 55" src="https://github.com/user-attachments/assets/ce1b5888-d24b-4adf-a422-694d4b0aa751" />
|
| 13 |
+
|
| 14 |
+
# LeLab official Space repository
|
| 15 |
+
|
| 16 |
+
If you've used [LeLab](https://huggingface.co/spaces/lerobot/LeLab) and want to contribute or found a bug, this is the place to be. This repo is directly hooked up to the Hugging Face space.
|
| 17 |
+
|
| 18 |
+
Here's the equivalent [backend](https://github.com/huggingface/leLab) that keeps the FastAPI server you run to actually wrap the LeRobot library.
|
| 19 |
+
|
| 20 |
+
We'll be updating this README shortly.
|
__init__.py
ADDED
|
File without changes
|
components.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://ui.shadcn.com/schema.json",
|
| 3 |
+
"style": "default",
|
| 4 |
+
"rsc": false,
|
| 5 |
+
"tsx": true,
|
| 6 |
+
"tailwind": {
|
| 7 |
+
"config": "tailwind.config.ts",
|
| 8 |
+
"css": "src/index.css",
|
| 9 |
+
"baseColor": "slate",
|
| 10 |
+
"cssVariables": true,
|
| 11 |
+
"prefix": ""
|
| 12 |
+
},
|
| 13 |
+
"aliases": {
|
| 14 |
+
"components": "@/components",
|
| 15 |
+
"utils": "@/lib/utils",
|
| 16 |
+
"ui": "@/components/ui",
|
| 17 |
+
"lib": "@/lib",
|
| 18 |
+
"hooks": "@/hooks"
|
| 19 |
+
}
|
| 20 |
+
}
|
eslint.config.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import js from "@eslint/js";
|
| 2 |
+
import globals from "globals";
|
| 3 |
+
import reactHooks from "eslint-plugin-react-hooks";
|
| 4 |
+
import reactRefresh from "eslint-plugin-react-refresh";
|
| 5 |
+
import tseslint from "typescript-eslint";
|
| 6 |
+
|
| 7 |
+
export default tseslint.config(
|
| 8 |
+
{ ignores: ["dist"] },
|
| 9 |
+
{
|
| 10 |
+
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
| 11 |
+
files: ["**/*.{ts,tsx}"],
|
| 12 |
+
languageOptions: {
|
| 13 |
+
ecmaVersion: 2020,
|
| 14 |
+
globals: globals.browser,
|
| 15 |
+
},
|
| 16 |
+
plugins: {
|
| 17 |
+
"react-hooks": reactHooks,
|
| 18 |
+
"react-refresh": reactRefresh,
|
| 19 |
+
},
|
| 20 |
+
rules: {
|
| 21 |
+
...reactHooks.configs.recommended.rules,
|
| 22 |
+
"react-refresh/only-export-components": [
|
| 23 |
+
"warn",
|
| 24 |
+
{ allowConstantExport: true },
|
| 25 |
+
],
|
| 26 |
+
"@typescript-eslint/no-unused-vars": "off",
|
| 27 |
+
},
|
| 28 |
+
}
|
| 29 |
+
);
|
index.html
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>LeLab</title>
|
| 7 |
+
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
| 8 |
+
<meta name="description" content="LeRobot but on your browser." />
|
| 9 |
+
|
| 10 |
+
<meta property="og:title" content="LeLab" />
|
| 11 |
+
<meta property="og:description" content="LeRobot but on your browser." />
|
| 12 |
+
<meta property="og:type" content="website" />
|
| 13 |
+
</head>
|
| 14 |
+
|
| 15 |
+
<body>
|
| 16 |
+
<div id="root"></div>
|
| 17 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 18 |
+
</body>
|
| 19 |
+
</html>
|
package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "vite_react_shadcn_ts",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "vite build",
|
| 9 |
+
"build:dev": "vite build --mode development",
|
| 10 |
+
"lint": "eslint .",
|
| 11 |
+
"preview": "vite preview"
|
| 12 |
+
},
|
| 13 |
+
"dependencies": {
|
| 14 |
+
"@hookform/resolvers": "^3.9.0",
|
| 15 |
+
"@radix-ui/react-accordion": "^1.2.0",
|
| 16 |
+
"@radix-ui/react-alert-dialog": "^1.1.1",
|
| 17 |
+
"@radix-ui/react-aspect-ratio": "^1.1.0",
|
| 18 |
+
"@radix-ui/react-avatar": "^1.1.0",
|
| 19 |
+
"@radix-ui/react-checkbox": "^1.1.1",
|
| 20 |
+
"@radix-ui/react-collapsible": "^1.1.0",
|
| 21 |
+
"@radix-ui/react-context-menu": "^2.2.1",
|
| 22 |
+
"@radix-ui/react-dialog": "^1.1.2",
|
| 23 |
+
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
| 24 |
+
"@radix-ui/react-hover-card": "^1.1.1",
|
| 25 |
+
"@radix-ui/react-label": "^2.1.0",
|
| 26 |
+
"@radix-ui/react-menubar": "^1.1.1",
|
| 27 |
+
"@radix-ui/react-navigation-menu": "^1.2.0",
|
| 28 |
+
"@radix-ui/react-popover": "^1.1.1",
|
| 29 |
+
"@radix-ui/react-progress": "^1.1.0",
|
| 30 |
+
"@radix-ui/react-radio-group": "^1.2.0",
|
| 31 |
+
"@radix-ui/react-scroll-area": "^1.1.0",
|
| 32 |
+
"@radix-ui/react-select": "^2.1.1",
|
| 33 |
+
"@radix-ui/react-separator": "^1.1.0",
|
| 34 |
+
"@radix-ui/react-slider": "^1.2.0",
|
| 35 |
+
"@radix-ui/react-slot": "^1.1.0",
|
| 36 |
+
"@radix-ui/react-switch": "^1.1.0",
|
| 37 |
+
"@radix-ui/react-tabs": "^1.1.0",
|
| 38 |
+
"@radix-ui/react-toast": "^1.2.1",
|
| 39 |
+
"@radix-ui/react-toggle": "^1.1.0",
|
| 40 |
+
"@radix-ui/react-toggle-group": "^1.1.0",
|
| 41 |
+
"@radix-ui/react-tooltip": "^1.1.4",
|
| 42 |
+
"@react-three/drei": "^9.122.0",
|
| 43 |
+
"@react-three/fiber": "^8.18.0",
|
| 44 |
+
"@tanstack/react-query": "^5.56.2",
|
| 45 |
+
"class-variance-authority": "^0.7.1",
|
| 46 |
+
"clsx": "^2.1.1",
|
| 47 |
+
"cmdk": "^1.0.0",
|
| 48 |
+
"date-fns": "^3.6.0",
|
| 49 |
+
"embla-carousel-react": "^8.3.0",
|
| 50 |
+
"input-otp": "^1.2.4",
|
| 51 |
+
"jszip": "^3.10.1",
|
| 52 |
+
"lucide-react": "^0.462.0",
|
| 53 |
+
"next-themes": "^0.3.0",
|
| 54 |
+
"react": "^18.3.1",
|
| 55 |
+
"react-day-picker": "^8.10.1",
|
| 56 |
+
"react-dom": "^18.3.1",
|
| 57 |
+
"react-hook-form": "^7.53.0",
|
| 58 |
+
"react-resizable-panels": "^2.1.3",
|
| 59 |
+
"react-router-dom": "^7.0.0",
|
| 60 |
+
"recharts": "^2.12.7",
|
| 61 |
+
"sonner": "^1.5.0",
|
| 62 |
+
"tailwind-merge": "^2.5.2",
|
| 63 |
+
"tailwindcss-animate": "^1.0.7",
|
| 64 |
+
"three": "^0.177.0",
|
| 65 |
+
"urdf-loader": "^0.12.6",
|
| 66 |
+
"vaul": "^0.9.3",
|
| 67 |
+
"zod": "^3.23.8"
|
| 68 |
+
},
|
| 69 |
+
"devDependencies": {
|
| 70 |
+
"@eslint/js": "^9.9.0",
|
| 71 |
+
"@tailwindcss/typography": "^0.5.15",
|
| 72 |
+
"@types/node": "^22.5.5",
|
| 73 |
+
"@types/react": "^18.3.3",
|
| 74 |
+
"@types/react-dom": "^18.3.0",
|
| 75 |
+
"@vitejs/plugin-react-swc": "^4.3.1",
|
| 76 |
+
"autoprefixer": "^10.4.20",
|
| 77 |
+
"eslint": "^9.9.0",
|
| 78 |
+
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
| 79 |
+
"eslint-plugin-react-refresh": "^0.4.9",
|
| 80 |
+
"globals": "^15.9.0",
|
| 81 |
+
"lovable-tagger": "^1.3.0",
|
| 82 |
+
"postcss": "^8.5.15",
|
| 83 |
+
"tailwindcss": "^3.4.11",
|
| 84 |
+
"typescript": "^5.5.3",
|
| 85 |
+
"typescript-eslint": "^8.0.1",
|
| 86 |
+
"vite": "^8.0.16"
|
| 87 |
+
}
|
| 88 |
+
}
|
postcss.config.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default {
|
| 2 |
+
plugins: {
|
| 3 |
+
tailwindcss: {},
|
| 4 |
+
autoprefixer: {},
|
| 5 |
+
},
|
| 6 |
+
}
|
public/favicon.ico
ADDED
|
|
public/lovable-uploads/5e648747-34b7-4d8f-93fd-4dbd00aeeefc.png
ADDED
|
public/placeholder.svg
ADDED
|
|
public/robots.txt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
User-agent: Googlebot
|
| 2 |
+
Allow: /
|
| 3 |
+
|
| 4 |
+
User-agent: Bingbot
|
| 5 |
+
Allow: /
|
| 6 |
+
|
| 7 |
+
User-agent: Twitterbot
|
| 8 |
+
Allow: /
|
| 9 |
+
|
| 10 |
+
User-agent: facebookexternalhit
|
| 11 |
+
Allow: /
|
| 12 |
+
|
| 13 |
+
User-agent: *
|
| 14 |
+
Allow: /
|
public/so-101-urdf/CMakeLists.txt
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
cmake_minimum_required(VERSION 3.10.2)
|
| 2 |
+
|
| 3 |
+
project(so_arm_description)
|
| 4 |
+
|
| 5 |
+
find_package(ament_cmake REQUIRED)
|
| 6 |
+
find_package(urdf REQUIRED)
|
| 7 |
+
|
| 8 |
+
# Install the mesh files from SO101/assets
|
| 9 |
+
install(
|
| 10 |
+
DIRECTORY
|
| 11 |
+
SO101/assets/
|
| 12 |
+
DESTINATION
|
| 13 |
+
share/${PROJECT_NAME}/meshes
|
| 14 |
+
FILES_MATCHING PATTERN "*.stl"
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
# Install URDF files
|
| 18 |
+
install(
|
| 19 |
+
DIRECTORY
|
| 20 |
+
urdf/
|
| 21 |
+
DESTINATION
|
| 22 |
+
share/${PROJECT_NAME}/urdf
|
| 23 |
+
FILES_MATCHING PATTERN "*.urdf"
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
# Install other directories
|
| 27 |
+
install(
|
| 28 |
+
DIRECTORY
|
| 29 |
+
meshes
|
| 30 |
+
config
|
| 31 |
+
launch
|
| 32 |
+
DESTINATION
|
| 33 |
+
share/${PROJECT_NAME}
|
| 34 |
+
OPTIONAL
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
ament_package()
|
public/so-101-urdf/README.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SO-ARM ROS2 URDF Package
|
| 2 |
+
|
| 3 |
+
A complete ROS2 package for the SO-ARM101 robotic arm with URDF description.
|
| 4 |
+
|
| 5 |
+
## 📋 Overview
|
| 6 |
+
|
| 7 |
+
This package provides a complete ROS2 implementation for the SO-ARM101 robotic arm, including:
|
| 8 |
+
|
| 9 |
+
- URDF robot description with visual and collision meshes
|
| 10 |
+
- RViz visualization with pre-configured displays
|
| 11 |
+
- Launch files for easy robot visualization
|
| 12 |
+
- Integration with MoveIt for motion planning
|
| 13 |
+
- Joint state publishers for interactive control
|
| 14 |
+
|
| 15 |
+
## 🎯 Original Source
|
| 16 |
+
|
| 17 |
+
https://github.com/TheRobotStudio/SO-ARM100/tree/main/Simulation/SO101
|
| 18 |
+
|
| 19 |
+
## 🚀 Key Improvements Made
|
| 20 |
+
|
| 21 |
+
### 1. **Complete ROS2 Package Structure**
|
| 22 |
+
|
| 23 |
+
- ✅ Proper `package.xml` with all necessary dependencies
|
| 24 |
+
- ✅ CMakeLists.txt for ROS2 build system
|
| 25 |
+
- ✅ Organized directory structure following ROS2 conventions
|
| 26 |
+
|
| 27 |
+
### 2. **Enhanced Visualization**
|
| 28 |
+
|
| 29 |
+
- ✅ Fixed mesh file paths for proper package integration
|
| 30 |
+
|
| 31 |
+
### Build Instructions
|
| 32 |
+
|
| 33 |
+
1. Clone this repository into your ROS2 workspace:
|
| 34 |
+
|
| 35 |
+
```bash
|
| 36 |
+
cd ~/your_ros2_ws/src
|
| 37 |
+
git clone <your-repo-url> so_arm_description
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
2. Build the package:
|
| 41 |
+
```bash
|
| 42 |
+
cd ~/your_ros2_ws
|
| 43 |
+
colcon build --packages-select so_arm_description
|
| 44 |
+
source install/setup.bash
|
| 45 |
+
```
|
public/so-101-urdf/config/joint_names_so_arm_urdf.yaml
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
controller_joint_names: ['', 'Rotation', 'Pitch', 'Elbow', 'Wrist_Pitch', 'Wrist_Roll', 'Jaw', ]
|
public/so-101-urdf/joints_properties.xml
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<default>
|
| 2 |
+
<default class="sts3215">
|
| 3 |
+
<geom contype="0" conaffinity="0"/>
|
| 4 |
+
<joint damping="0.60" frictionloss="0.052" armature="0.028"/>
|
| 5 |
+
<position kp="17.8" kv="0.0" forcerange="-3.35 3.35"/>
|
| 6 |
+
</default>
|
| 7 |
+
<default class="backlash">
|
| 8 |
+
<!-- +/- 0.5° of backlash -->
|
| 9 |
+
<joint damping="0.01" frictionloss="0" armature="0.01" limited="true"
|
| 10 |
+
range="-0.008726646259971648 0.008726646259971648"/>
|
| 11 |
+
</default>
|
| 12 |
+
</default>
|
public/so-101-urdf/meshes/base_motor_holder_so101_v1.stl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:8cd2f241037ea377af1191fffe0dd9d9006beea6dcc48543660ed41647072424
|
| 3 |
+
size 1877084
|
public/so-101-urdf/meshes/base_so101_v2.stl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:bb12b7026575e1f70ccc7240051f9d943553bf34e5128537de6cd86fae33924d
|
| 3 |
+
size 471584
|
public/so-101-urdf/meshes/motor_holder_so101_base_v1.stl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:31242ae6fb59d8b15c66617b88ad8e9bded62d57c35d11c0c43a70d2f4caa95b
|
| 3 |
+
size 1129384
|
public/so-101-urdf/meshes/motor_holder_so101_wrist_v1.stl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:887f92e6013cb64ea3a1ab8675e92da1e0beacfd5e001f972523540545e08011
|
| 3 |
+
size 1052184
|
public/so-101-urdf/meshes/moving_jaw_so101_v1.stl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:785a9dded2f474bc1d869e0d3dae398a3dcd9c0c345640040472210d2861fa9d
|
| 3 |
+
size 1413584
|
public/so-101-urdf/meshes/rotation_pitch_so101_v1.stl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:9be900cc2a2bf718102841ef82ef8d2873842427648092c8ed2ca1e2ef4ffa34
|
| 3 |
+
size 883684
|
public/so-101-urdf/meshes/sts3215_03a_no_horn_v1.stl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:75ef3781b752e4065891aea855e34dc161a38a549549cd0970cedd07eae6f887
|
| 3 |
+
size 865884
|
public/so-101-urdf/meshes/sts3215_03a_v1.stl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a37c871fb502483ab96c256baf457d36f2e97afc9205313d9c5ab275ef941cd0
|
| 3 |
+
size 954084
|
public/so-101-urdf/meshes/under_arm_so101_v1.stl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d01d1f2de365651dcad9d6669e94ff87ff7652b5bb2d10752a66a456a86dbc71
|
| 3 |
+
size 1975884
|
public/so-101-urdf/meshes/upper_arm_so101_v1.stl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:475056e03a17e71919b82fd88ab9a0b898ab50164f2a7943652a6b2941bb2d4f
|
| 3 |
+
size 1303484
|
public/so-101-urdf/meshes/waveshare_mounting_plate_so101_v2.stl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:e197e24005a07d01bbc06a8c42311664eaeda415bf859f68fa247884d0f1a6e9
|
| 3 |
+
size 62784
|
public/so-101-urdf/meshes/wrist_roll_follower_so101_v1.stl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:4b17b410a12d64ec39554abc3e8054d8a97384b2dc4a8d95a5ecb2a93670f5f4
|
| 3 |
+
size 1439884
|
public/so-101-urdf/meshes/wrist_roll_pitch_so101_v2.stl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:6c7ec5525b4d8b9e397a30ab4bb0037156a5d5f38a4adf2c7d943d6c56eda5ae
|
| 3 |
+
size 2699784
|
public/so-101-urdf/package.xml
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?xml version="1.0"?>
|
| 2 |
+
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
|
| 3 |
+
<package format="3">
|
| 4 |
+
<name>so_arm_description</name>
|
| 5 |
+
<version>1.0.0</version>
|
| 6 |
+
<description>SO-ARM101 URDF Resources</description>
|
| 7 |
+
|
| 8 |
+
<author email="contact@lycheeai-hub.com">LycheeAI</author>
|
| 9 |
+
|
| 10 |
+
<maintainer email="contact@lycheeai-hub.com">LycheeAI</maintainer>
|
| 11 |
+
|
| 12 |
+
<license>BSD</license>
|
| 13 |
+
|
| 14 |
+
<buildtool_depend>ament_cmake</buildtool_depend>
|
| 15 |
+
|
| 16 |
+
<depend>urdf</depend>
|
| 17 |
+
<exec_depend>robot_state_publisher</exec_depend>
|
| 18 |
+
<exec_depend>joint_state_publisher</exec_depend>
|
| 19 |
+
<exec_depend>joint_state_publisher_gui</exec_depend>
|
| 20 |
+
<exec_depend>rviz2</exec_depend>
|
| 21 |
+
<exec_depend>xacro</exec_depend>
|
| 22 |
+
|
| 23 |
+
<export>
|
| 24 |
+
<build_type>ament_cmake</build_type>
|
| 25 |
+
</export>
|
| 26 |
+
</package>
|
public/so-101-urdf/urdf/so101_new_calib.urdf
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?xml version='1.0' encoding='utf-8'?>
|
| 2 |
+
<!-- Generated using onshape-to-robot -->
|
| 3 |
+
<!-- Onshape https://cad.onshape.com/documents/7715cc284bb430fe6dab4ffd/w/4fd0791b683777b02f8d975a/e/826c553ede3b7592eb9ca800 -->
|
| 4 |
+
<robot name="so101_new_calib">
|
| 5 |
+
|
| 6 |
+
<!-- Materials -->
|
| 7 |
+
<material name="3d_printed">
|
| 8 |
+
<color rgba="1.0 0.82 0.12 1.0"/>
|
| 9 |
+
</material>
|
| 10 |
+
<material name="sts3215">
|
| 11 |
+
<color rgba="0.1 0.1 0.1 1.0"/>
|
| 12 |
+
</material>
|
| 13 |
+
|
| 14 |
+
<!-- Link base -->
|
| 15 |
+
<link name="base">
|
| 16 |
+
<inertial>
|
| 17 |
+
<origin xyz="0.020739 0.00204287 0.065966" rpy="0 0 0"/>
|
| 18 |
+
<mass value="0.147"/>
|
| 19 |
+
<inertia ixx="0.000136117" ixy="4.59787e-07" ixz="9.75275e-08" iyy="0.000114686" iyz="-4.97151e-06" izz="0.000130364"/>
|
| 20 |
+
</inertial>
|
| 21 |
+
<!-- Part base_motor_holder_so101_v1 -->
|
| 22 |
+
<visual>
|
| 23 |
+
<origin xyz="0.0206915 0.0221255 0.0300817" rpy="1.5708 -1.23909e-16 2.33147e-15"/>
|
| 24 |
+
<geometry>
|
| 25 |
+
<mesh filename="package://so_arm_description/meshes/base_motor_holder_so101_v1.stl"/>
|
| 26 |
+
</geometry>
|
| 27 |
+
<material name="3d_printed"/>
|
| 28 |
+
</visual>
|
| 29 |
+
<collision>
|
| 30 |
+
<origin xyz="0.0206915 0.0221255 0.0300817" rpy="1.5708 -1.23909e-16 2.33147e-15"/>
|
| 31 |
+
<geometry>
|
| 32 |
+
<mesh filename="package://so_arm_description/meshes/base_motor_holder_so101_v1.stl"/>
|
| 33 |
+
</geometry>
|
| 34 |
+
</collision>
|
| 35 |
+
<!-- Part base_so101_v2 -->
|
| 36 |
+
<visual>
|
| 37 |
+
<origin xyz="0.0207909 0.0221255 0.0300817" rpy="1.5708 -0 0"/>
|
| 38 |
+
<geometry>
|
| 39 |
+
<mesh filename="package://so_arm_description/meshes/base_so101_v2.stl"/>
|
| 40 |
+
</geometry>
|
| 41 |
+
<material name="3d_printed"/>
|
| 42 |
+
</visual>
|
| 43 |
+
<collision>
|
| 44 |
+
<origin xyz="0.0207909 0.0221255 0.0300817" rpy="1.5708 -0 0"/>
|
| 45 |
+
<geometry>
|
| 46 |
+
<mesh filename="package://so_arm_description/meshes/base_so101_v2.stl"/>
|
| 47 |
+
</geometry>
|
| 48 |
+
</collision>
|
| 49 |
+
<!-- Part sts3215_03a_v1 -->
|
| 50 |
+
<visual>
|
| 51 |
+
<origin xyz="0.0207909 -0.0105745 0.0761817" rpy="-2.20282e-15 2.77556e-17 -1.5708"/>
|
| 52 |
+
<geometry>
|
| 53 |
+
<mesh filename="package://so_arm_description/meshes/sts3215_03a_v1.stl"/>
|
| 54 |
+
</geometry>
|
| 55 |
+
<material name="sts3215"/>
|
| 56 |
+
</visual>
|
| 57 |
+
<collision>
|
| 58 |
+
<origin xyz="0.0207909 -0.0105745 0.0761817" rpy="-2.20282e-15 2.77556e-17 -1.5708"/>
|
| 59 |
+
<geometry>
|
| 60 |
+
<mesh filename="package://so_arm_description/meshes/sts3215_03a_v1.stl"/>
|
| 61 |
+
</geometry>
|
| 62 |
+
</collision>
|
| 63 |
+
<!-- Part waveshare_mounting_plate_so101_v2 -->
|
| 64 |
+
<visual>
|
| 65 |
+
<origin xyz="0.0205915 0.0467435 0.0798817" rpy="1.5708 -1.21716e-14 2.33147e-15"/>
|
| 66 |
+
<geometry>
|
| 67 |
+
<mesh filename="package://so_arm_description/meshes/waveshare_mounting_plate_so101_v2.stl"/>
|
| 68 |
+
</geometry>
|
| 69 |
+
<material name="3d_printed"/>
|
| 70 |
+
</visual>
|
| 71 |
+
<collision>
|
| 72 |
+
<origin xyz="0.0205915 0.0467435 0.0798817" rpy="1.5708 -1.21716e-14 2.33147e-15"/>
|
| 73 |
+
<geometry>
|
| 74 |
+
<mesh filename="package://so_arm_description/meshes/waveshare_mounting_plate_so101_v2.stl"/>
|
| 75 |
+
</geometry>
|
| 76 |
+
</collision>
|
| 77 |
+
</link>
|
| 78 |
+
|
| 79 |
+
<!-- Link shoulder -->
|
| 80 |
+
<link name="shoulder">
|
| 81 |
+
<inertial>
|
| 82 |
+
<origin xyz="-0.0307604 -1.66727e-05 -0.0252713" rpy="0 0 0"/>
|
| 83 |
+
<mass value="0.100006"/>
|
| 84 |
+
<inertia ixx="8.3759e-05" ixy="7.55525e-08" ixz="-1.16342e-06" iyy="8.10403e-05" iyz="1.54663e-07" izz="2.39783e-05"/>
|
| 85 |
+
</inertial>
|
| 86 |
+
<!-- Part sts3215_03a_v1_2 -->
|
| 87 |
+
<visual>
|
| 88 |
+
<origin xyz="-0.0303992 0.000422241 -0.0417" rpy="1.5708 1.5708 0"/>
|
| 89 |
+
<geometry>
|
| 90 |
+
<mesh filename="package://so_arm_description/meshes/sts3215_03a_v1.stl"/>
|
| 91 |
+
</geometry>
|
| 92 |
+
<material name="sts3215"/>
|
| 93 |
+
</visual>
|
| 94 |
+
<collision>
|
| 95 |
+
<origin xyz="-0.0303992 0.000422241 -0.0417" rpy="1.5708 1.5708 0"/>
|
| 96 |
+
<geometry>
|
| 97 |
+
<mesh filename="package://so_arm_description/meshes/sts3215_03a_v1.stl"/>
|
| 98 |
+
</geometry>
|
| 99 |
+
</collision>
|
| 100 |
+
<!-- Part motor_holder_so101_base_v1 -->
|
| 101 |
+
<visual>
|
| 102 |
+
<origin xyz="-0.0675992 -0.000177759 0.0158499" rpy="1.5708 -1.5708 0"/>
|
| 103 |
+
<geometry>
|
| 104 |
+
<mesh filename="package://so_arm_description/meshes/motor_holder_so101_base_v1.stl"/>
|
| 105 |
+
</geometry>
|
| 106 |
+
<material name="3d_printed"/>
|
| 107 |
+
</visual>
|
| 108 |
+
<collision>
|
| 109 |
+
<origin xyz="-0.0675992 -0.000177759 0.0158499" rpy="1.5708 -1.5708 0"/>
|
| 110 |
+
<geometry>
|
| 111 |
+
<mesh filename="package://so_arm_description/meshes/motor_holder_so101_base_v1.stl"/>
|
| 112 |
+
</geometry>
|
| 113 |
+
</collision>
|
| 114 |
+
<!-- Part rotation_pitch_so101_v1 -->
|
| 115 |
+
<visual>
|
| 116 |
+
<origin xyz="0.0122008 2.22413e-05 0.0464" rpy="-1.5708 -0 0"/>
|
| 117 |
+
<geometry>
|
| 118 |
+
<mesh filename="package://so_arm_description/meshes/rotation_pitch_so101_v1.stl"/>
|
| 119 |
+
</geometry>
|
| 120 |
+
<material name="3d_printed"/>
|
| 121 |
+
</visual>
|
| 122 |
+
<collision>
|
| 123 |
+
<origin xyz="0.0122008 2.22413e-05 0.0464" rpy="-1.5708 -0 0"/>
|
| 124 |
+
<geometry>
|
| 125 |
+
<mesh filename="package://so_arm_description/meshes/rotation_pitch_so101_v1.stl"/>
|
| 126 |
+
</geometry>
|
| 127 |
+
</collision>
|
| 128 |
+
</link>
|
| 129 |
+
|
| 130 |
+
<!-- Link upper_arm -->
|
| 131 |
+
<link name="upper_arm">
|
| 132 |
+
<inertial>
|
| 133 |
+
<origin xyz="-0.0898471 -0.00838224 0.0184089" rpy="0 0 0"/>
|
| 134 |
+
<mass value="0.103"/>
|
| 135 |
+
<inertia ixx="4.08002e-05" ixy="-1.97819e-05" ixz="-4.03016e-08" iyy="0.000147318" iyz="8.97326e-09" izz="0.000142487"/>
|
| 136 |
+
</inertial>
|
| 137 |
+
<!-- Part sts3215_03a_v1_3 -->
|
| 138 |
+
<visual>
|
| 139 |
+
<origin xyz="-0.11257 -0.0155 0.0187" rpy="-3.14159 -6.8695e-16 -1.5708"/>
|
| 140 |
+
<geometry>
|
| 141 |
+
<mesh filename="package://so_arm_description/meshes/sts3215_03a_v1.stl"/>
|
| 142 |
+
</geometry>
|
| 143 |
+
<material name="sts3215"/>
|
| 144 |
+
</visual>
|
| 145 |
+
<collision>
|
| 146 |
+
<origin xyz="-0.11257 -0.0155 0.0187" rpy="-3.14159 -6.8695e-16 -1.5708"/>
|
| 147 |
+
<geometry>
|
| 148 |
+
<mesh filename="package://so_arm_description/meshes/sts3215_03a_v1.stl"/>
|
| 149 |
+
</geometry>
|
| 150 |
+
</collision>
|
| 151 |
+
<!-- Part upper_arm_so101_v1 -->
|
| 152 |
+
<visual>
|
| 153 |
+
<origin xyz="-0.065085 0.012 0.0182" rpy="3.14159 -9.35612e-32 0"/>
|
| 154 |
+
<geometry>
|
| 155 |
+
<mesh filename="package://so_arm_description/meshes/upper_arm_so101_v1.stl"/>
|
| 156 |
+
</geometry>
|
| 157 |
+
<material name="3d_printed"/>
|
| 158 |
+
</visual>
|
| 159 |
+
<collision>
|
| 160 |
+
<origin xyz="-0.065085 0.012 0.0182" rpy="3.14159 -9.35612e-32 0"/>
|
| 161 |
+
<geometry>
|
| 162 |
+
<mesh filename="package://so_arm_description/meshes/upper_arm_so101_v1.stl"/>
|
| 163 |
+
</geometry>
|
| 164 |
+
</collision>
|
| 165 |
+
</link>
|
| 166 |
+
|
| 167 |
+
<!-- Link lower_arm -->
|
| 168 |
+
<link name="lower_arm">
|
| 169 |
+
<inertial>
|
| 170 |
+
<origin xyz="-0.0980701 0.00324376 0.0182831" rpy="0 0 0"/>
|
| 171 |
+
<mass value="0.104"/>
|
| 172 |
+
<inertia ixx="2.87438e-05" ixy="7.41152e-06" ixz="1.26409e-06" iyy="0.000159844" iyz="-4.90188e-08" izz="0.00014529"/>
|
| 173 |
+
</inertial>
|
| 174 |
+
<!-- Part under_arm_so101_v1 -->
|
| 175 |
+
<visual>
|
| 176 |
+
<origin xyz="-0.0648499 -0.032 0.0182" rpy="-3.14159 -0 3.9443e-31"/>
|
| 177 |
+
<geometry>
|
| 178 |
+
<mesh filename="package://so_arm_description/meshes/under_arm_so101_v1.stl"/>
|
| 179 |
+
</geometry>
|
| 180 |
+
<material name="3d_printed"/>
|
| 181 |
+
</visual>
|
| 182 |
+
<collision>
|
| 183 |
+
<origin xyz="-0.0648499 -0.032 0.0182" rpy="-3.14159 -0 3.9443e-31"/>
|
| 184 |
+
<geometry>
|
| 185 |
+
<mesh filename="package://so_arm_description/meshes/under_arm_so101_v1.stl"/>
|
| 186 |
+
</geometry>
|
| 187 |
+
</collision>
|
| 188 |
+
<!-- Part motor_holder_so101_wrist_v1 -->
|
| 189 |
+
<visual>
|
| 190 |
+
<origin xyz="-0.0648499 -0.032 0.018" rpy="-3.14159 4.73317e-30 7.88861e-31"/>
|
| 191 |
+
<geometry>
|
| 192 |
+
<mesh filename="package://so_arm_description/meshes/motor_holder_so101_wrist_v1.stl"/>
|
| 193 |
+
</geometry>
|
| 194 |
+
<material name="3d_printed"/>
|
| 195 |
+
</visual>
|
| 196 |
+
<collision>
|
| 197 |
+
<origin xyz="-0.0648499 -0.032 0.018" rpy="-3.14159 4.73317e-30 7.88861e-31"/>
|
| 198 |
+
<geometry>
|
| 199 |
+
<mesh filename="package://so_arm_description/meshes/motor_holder_so101_wrist_v1.stl"/>
|
| 200 |
+
</geometry>
|
| 201 |
+
</collision>
|
| 202 |
+
<!-- Part sts3215_03a_v1_4 -->
|
| 203 |
+
<visual>
|
| 204 |
+
<origin xyz="-0.1224 0.0052 0.0187" rpy="-3.14159 -3.58047e-15 -3.14159"/>
|
| 205 |
+
<geometry>
|
| 206 |
+
<mesh filename="package://so_arm_description/meshes/sts3215_03a_v1.stl"/>
|
| 207 |
+
</geometry>
|
| 208 |
+
<material name="sts3215"/>
|
| 209 |
+
</visual>
|
| 210 |
+
<collision>
|
| 211 |
+
<origin xyz="-0.1224 0.0052 0.0187" rpy="-3.14159 -3.58047e-15 -3.14159"/>
|
| 212 |
+
<geometry>
|
| 213 |
+
<mesh filename="package://so_arm_description/meshes/sts3215_03a_v1.stl"/>
|
| 214 |
+
</geometry>
|
| 215 |
+
</collision>
|
| 216 |
+
</link>
|
| 217 |
+
|
| 218 |
+
<!-- Link wrist -->
|
| 219 |
+
<link name="wrist">
|
| 220 |
+
<inertial>
|
| 221 |
+
<origin xyz="-0.000103312 -0.0386143 0.0281156" rpy="0 0 0"/>
|
| 222 |
+
<mass value="0.079"/>
|
| 223 |
+
<inertia ixx="3.68263e-05" ixy="1.7893e-08" ixz="-5.28128e-08" iyy="2.5391e-05" iyz="3.6412e-06" izz="2.1e-05"/>
|
| 224 |
+
</inertial>
|
| 225 |
+
<!-- Part sts3215_03a_no_horn_v1 -->
|
| 226 |
+
<visual>
|
| 227 |
+
<origin xyz="5.55112e-17 -0.0424 0.0306" rpy="1.5708 1.5708 0"/>
|
| 228 |
+
<geometry>
|
| 229 |
+
<mesh filename="package://so_arm_description/meshes/sts3215_03a_no_horn_v1.stl"/>
|
| 230 |
+
</geometry>
|
| 231 |
+
<material name="sts3215"/>
|
| 232 |
+
</visual>
|
| 233 |
+
<collision>
|
| 234 |
+
<origin xyz="5.55112e-17 -0.0424 0.0306" rpy="1.5708 1.5708 0"/>
|
| 235 |
+
<geometry>
|
| 236 |
+
<mesh filename="package://so_arm_description/meshes/sts3215_03a_no_horn_v1.stl"/>
|
| 237 |
+
</geometry>
|
| 238 |
+
</collision>
|
| 239 |
+
<!-- Part wrist_roll_pitch_so101_v2 -->
|
| 240 |
+
<visual>
|
| 241 |
+
<origin xyz="0 -0.028 0.0181" rpy="-1.5708 -1.5708 0"/>
|
| 242 |
+
<geometry>
|
| 243 |
+
<mesh filename="package://so_arm_description/meshes/wrist_roll_pitch_so101_v2.stl"/>
|
| 244 |
+
</geometry>
|
| 245 |
+
<material name="3d_printed"/>
|
| 246 |
+
</visual>
|
| 247 |
+
<collision>
|
| 248 |
+
<origin xyz="0 -0.028 0.0181" rpy="-1.5708 -1.5708 0"/>
|
| 249 |
+
<geometry>
|
| 250 |
+
<mesh filename="package://so_arm_description/meshes/wrist_roll_pitch_so101_v2.stl"/>
|
| 251 |
+
</geometry>
|
| 252 |
+
</collision>
|
| 253 |
+
</link>
|
| 254 |
+
|
| 255 |
+
<!-- Link gripper -->
|
| 256 |
+
<link name="gripper">
|
| 257 |
+
<inertial>
|
| 258 |
+
<origin xyz="0.000213627 0.000245138 -0.025187" rpy="0 0 0"/>
|
| 259 |
+
<mass value="0.087"/>
|
| 260 |
+
<inertia ixx="2.75087e-05" ixy="-3.35241e-07" ixz="-5.7352e-06" iyy="4.33657e-05" iyz="-5.17847e-08" izz="3.45059e-05"/>
|
| 261 |
+
</inertial>
|
| 262 |
+
<!-- Part sts3215_03a_v1_5 -->
|
| 263 |
+
<visual>
|
| 264 |
+
<origin xyz="0.0077 0.0001 -0.0234" rpy="-1.5708 -5.55112e-17 -1.38213e-14"/>
|
| 265 |
+
<geometry>
|
| 266 |
+
<mesh filename="package://so_arm_description/meshes/sts3215_03a_v1.stl"/>
|
| 267 |
+
</geometry>
|
| 268 |
+
<material name="sts3215"/>
|
| 269 |
+
</visual>
|
| 270 |
+
<collision>
|
| 271 |
+
<origin xyz="0.0077 0.0001 -0.0234" rpy="-1.5708 -5.55112e-17 -1.38213e-14"/>
|
| 272 |
+
<geometry>
|
| 273 |
+
<mesh filename="package://so_arm_description/meshes/sts3215_03a_v1.stl"/>
|
| 274 |
+
</geometry>
|
| 275 |
+
</collision>
|
| 276 |
+
<!-- Part wrist_roll_follower_so101_v1 -->
|
| 277 |
+
<visual>
|
| 278 |
+
<origin xyz="5.55112e-17 -0.000218214 0.000949706" rpy="-3.14159 -5.55112e-17 -9.17912e-24"/>
|
| 279 |
+
<geometry>
|
| 280 |
+
<mesh filename="package://so_arm_description/meshes/wrist_roll_follower_so101_v1.stl"/>
|
| 281 |
+
</geometry>
|
| 282 |
+
<material name="3d_printed"/>
|
| 283 |
+
</visual>
|
| 284 |
+
<collision>
|
| 285 |
+
<origin xyz="5.55112e-17 -0.000218214 0.000949706" rpy="-3.14159 -5.55112e-17 -9.17912e-24"/>
|
| 286 |
+
<geometry>
|
| 287 |
+
<mesh filename="package://so_arm_description/meshes/wrist_roll_follower_so101_v1.stl"/>
|
| 288 |
+
</geometry>
|
| 289 |
+
</collision>
|
| 290 |
+
</link>
|
| 291 |
+
|
| 292 |
+
<!-- Link jaw -->
|
| 293 |
+
<link name="jaw">
|
| 294 |
+
<inertial>
|
| 295 |
+
<origin xyz="-0.00157495 -0.0300244 0.0192755" rpy="0 0 0"/>
|
| 296 |
+
<mass value="0.012"/>
|
| 297 |
+
<inertia ixx="6.61427e-06" ixy="-3.19807e-07" ixz="-5.90717e-09" iyy="1.89032e-06" iyz="-1.09945e-07" izz="5.28738e-06"/>
|
| 298 |
+
</inertial>
|
| 299 |
+
<!-- Part moving_jaw_so101_v1 -->
|
| 300 |
+
<visual>
|
| 301 |
+
<origin xyz="-5.55112e-17 -1.94746e-17 0.0189" rpy="9.53145e-17 -4.66093e-24 0"/>
|
| 302 |
+
<geometry>
|
| 303 |
+
<mesh filename="package://so_arm_description/meshes/moving_jaw_so101_v1.stl"/>
|
| 304 |
+
</geometry>
|
| 305 |
+
<material name="3d_printed"/>
|
| 306 |
+
</visual>
|
| 307 |
+
<collision>
|
| 308 |
+
<origin xyz="-5.55112e-17 -1.94746e-17 0.0189" rpy="9.53145e-17 -4.66093e-24 0"/>
|
| 309 |
+
<geometry>
|
| 310 |
+
<mesh filename="package://so_arm_description/meshes/moving_jaw_so101_v1.stl"/>
|
| 311 |
+
</geometry>
|
| 312 |
+
</collision>
|
| 313 |
+
</link>
|
| 314 |
+
|
| 315 |
+
<!-- Joint from gripper to jaw -->
|
| 316 |
+
<joint name="Jaw" type="revolute">
|
| 317 |
+
<origin xyz="0.0202 0.0188 -0.0234" rpy="1.5708 -5.14108e-17 -1.38655e-14"/>
|
| 318 |
+
<parent link="gripper"/>
|
| 319 |
+
<child link="jaw"/>
|
| 320 |
+
<axis xyz="0 0 1"/>
|
| 321 |
+
<limit effort="10" velocity="10" lower="-0.174533" upper="1.74533"/>
|
| 322 |
+
</joint>
|
| 323 |
+
|
| 324 |
+
<transmission name="6_trans">
|
| 325 |
+
<type>transmission_interface/SimpleTransmission</type>
|
| 326 |
+
<joint name="Jaw">
|
| 327 |
+
<hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface>
|
| 328 |
+
</joint>
|
| 329 |
+
<actuator name="motor6">
|
| 330 |
+
<hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface>
|
| 331 |
+
<mechanicalReduction>1</mechanicalReduction>
|
| 332 |
+
</actuator>
|
| 333 |
+
</transmission>
|
| 334 |
+
|
| 335 |
+
<!-- Joint from wrist to gripper -->
|
| 336 |
+
<joint name="Wrist_Roll" type="revolute">
|
| 337 |
+
<origin xyz="0 -0.0611 0.0181" rpy="1.5708 -9.38083e-08 3.14159"/>
|
| 338 |
+
<parent link="wrist"/>
|
| 339 |
+
<child link="gripper"/>
|
| 340 |
+
<axis xyz="0 0 1"/>
|
| 341 |
+
<limit effort="10" velocity="10" lower="-2.79253" upper="2.79253"/>
|
| 342 |
+
</joint>
|
| 343 |
+
|
| 344 |
+
<transmission name="5_trans">
|
| 345 |
+
<type>transmission_interface/SimpleTransmission</type>
|
| 346 |
+
<joint name="Wrist_Roll">
|
| 347 |
+
<hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface>
|
| 348 |
+
</joint>
|
| 349 |
+
<actuator name="motor5">
|
| 350 |
+
<hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface>
|
| 351 |
+
<mechanicalReduction>1</mechanicalReduction>
|
| 352 |
+
</actuator>
|
| 353 |
+
</transmission>
|
| 354 |
+
|
| 355 |
+
<!-- Joint from lower_arm to wrist -->
|
| 356 |
+
<joint name="Wrist_Pitch" type="revolute">
|
| 357 |
+
<origin xyz="-0.1349 0.0052 1.65232e-16" rpy="3.2474e-15 2.86219e-15 -1.5708"/>
|
| 358 |
+
<parent link="lower_arm"/>
|
| 359 |
+
<child link="wrist"/>
|
| 360 |
+
<axis xyz="0 0 1"/>
|
| 361 |
+
<limit effort="10" velocity="10" lower="-1.65806" upper="1.65806"/>
|
| 362 |
+
</joint>
|
| 363 |
+
|
| 364 |
+
<transmission name="4_trans">
|
| 365 |
+
<type>transmission_interface/SimpleTransmission</type>
|
| 366 |
+
<joint name="Wrist_Pitch">
|
| 367 |
+
<hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface>
|
| 368 |
+
</joint>
|
| 369 |
+
<actuator name="motor4">
|
| 370 |
+
<hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface>
|
| 371 |
+
<mechanicalReduction>1</mechanicalReduction>
|
| 372 |
+
</actuator>
|
| 373 |
+
</transmission>
|
| 374 |
+
|
| 375 |
+
<!-- Joint from upper_arm to lower_arm -->
|
| 376 |
+
<joint name="Elbow" type="revolute">
|
| 377 |
+
<origin xyz="-0.11257 -0.028 2.46331e-16" rpy="-1.22818e-15 5.75928e-16 1.5708"/>
|
| 378 |
+
<parent link="upper_arm"/>
|
| 379 |
+
<child link="lower_arm"/>
|
| 380 |
+
<axis xyz="0 0 1"/>
|
| 381 |
+
<limit effort="10" velocity="10" lower="-1.74533" upper="1.5708"/>
|
| 382 |
+
</joint>
|
| 383 |
+
|
| 384 |
+
<transmission name="3_trans">
|
| 385 |
+
<type>transmission_interface/SimpleTransmission</type>
|
| 386 |
+
<joint name="Elbow">
|
| 387 |
+
<hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface>
|
| 388 |
+
</joint>
|
| 389 |
+
<actuator name="motor3">
|
| 390 |
+
<hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface>
|
| 391 |
+
<mechanicalReduction>1</mechanicalReduction>
|
| 392 |
+
</actuator>
|
| 393 |
+
</transmission>
|
| 394 |
+
|
| 395 |
+
<!-- Joint from shoulder to upper_arm -->
|
| 396 |
+
<joint name="Pitch" type="revolute">
|
| 397 |
+
<origin xyz="-0.0303992 -0.0182778 -0.0542" rpy="-1.5708 -1.5708 0"/>
|
| 398 |
+
<parent link="shoulder"/>
|
| 399 |
+
<child link="upper_arm"/>
|
| 400 |
+
<axis xyz="0 0 1"/>
|
| 401 |
+
<limit effort="10" velocity="10" lower="-1.74533" upper="1.74533"/>
|
| 402 |
+
</joint>
|
| 403 |
+
|
| 404 |
+
<transmission name="2_trans">
|
| 405 |
+
<type>transmission_interface/SimpleTransmission</type>
|
| 406 |
+
<joint name="Pitch">
|
| 407 |
+
<hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface>
|
| 408 |
+
</joint>
|
| 409 |
+
<actuator name="motor2">
|
| 410 |
+
<hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface>
|
| 411 |
+
<mechanicalReduction>1</mechanicalReduction>
|
| 412 |
+
</actuator>
|
| 413 |
+
</transmission>
|
| 414 |
+
|
| 415 |
+
<!-- Joint from base to shoulder -->
|
| 416 |
+
<joint name="Rotation" type="revolute">
|
| 417 |
+
<origin xyz="0.0207909 -0.0230745 0.0948817" rpy="-3.14159 6.03684e-16 1.5708"/>
|
| 418 |
+
<parent link="base"/>
|
| 419 |
+
<child link="shoulder"/>
|
| 420 |
+
<axis xyz="0 0 1"/>
|
| 421 |
+
<limit effort="10" velocity="10" lower="-1.91986" upper="1.91986"/>
|
| 422 |
+
</joint>
|
| 423 |
+
|
| 424 |
+
<transmission name="1_trans">
|
| 425 |
+
<type>transmission_interface/SimpleTransmission</type>
|
| 426 |
+
<joint name="Rotation">
|
| 427 |
+
<hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface>
|
| 428 |
+
</joint>
|
| 429 |
+
<actuator name="motor1">
|
| 430 |
+
<hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface>
|
| 431 |
+
<mechanicalReduction>1</mechanicalReduction>
|
| 432 |
+
</actuator>
|
| 433 |
+
</transmission>
|
| 434 |
+
|
| 435 |
+
</robot>
|
src/.gitignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
node_modules/
|
src/App.tsx
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
| 2 |
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
| 3 |
+
import { ThemeProvider } from "@/contexts/ThemeContext";
|
| 4 |
+
import { UrdfProvider } from "@/contexts/UrdfContext";
|
| 5 |
+
import { DragAndDropProvider } from "@/contexts/DragAndDropContext";
|
| 6 |
+
import { Toaster } from "@/components/ui/toaster";
|
| 7 |
+
import Landing from "@/pages/Landing";
|
| 8 |
+
import Teleoperation from "@/pages/Teleoperation";
|
| 9 |
+
import Calibration from "@/pages/Calibration";
|
| 10 |
+
import Recording from "@/pages/Recording";
|
| 11 |
+
import Training from "@/pages/Training";
|
| 12 |
+
import Inference from "@/pages/Inference";
|
| 13 |
+
import EditDataset from "@/pages/EditDataset";
|
| 14 |
+
import Upload from "@/pages/Upload";
|
| 15 |
+
|
| 16 |
+
import NotFound from "@/pages/NotFound";
|
| 17 |
+
import SingleTabGuard from "@/components/SingleTabGuard";
|
| 18 |
+
import TeleopStopNotice from "@/components/TeleopStopNotice";
|
| 19 |
+
import UpdateNotice from "@/components/UpdateNotice";
|
| 20 |
+
import { TooltipProvider } from "@radix-ui/react-tooltip";
|
| 21 |
+
import { ApiProvider } from "./contexts/ApiContext";
|
| 22 |
+
import { HfAuthProvider } from "./contexts/HfAuthContext";
|
| 23 |
+
|
| 24 |
+
const queryClient = new QueryClient();
|
| 25 |
+
|
| 26 |
+
function App() {
|
| 27 |
+
return (
|
| 28 |
+
<QueryClientProvider client={queryClient}>
|
| 29 |
+
<TooltipProvider>
|
| 30 |
+
<ThemeProvider>
|
| 31 |
+
<ApiProvider>
|
| 32 |
+
<HfAuthProvider>
|
| 33 |
+
<UrdfProvider>
|
| 34 |
+
<DragAndDropProvider>
|
| 35 |
+
<BrowserRouter>
|
| 36 |
+
<SingleTabGuard>
|
| 37 |
+
<TeleopStopNotice />
|
| 38 |
+
<UpdateNotice />
|
| 39 |
+
<Routes>
|
| 40 |
+
<Route path="/" element={<Landing />} />
|
| 41 |
+
<Route path="/teleoperation" element={<Teleoperation />} />
|
| 42 |
+
<Route path="/recording" element={<Recording />} />
|
| 43 |
+
<Route path="/upload" element={<Upload />} />
|
| 44 |
+
<Route path="/training" element={<Training />} />
|
| 45 |
+
<Route path="/training/:jobId" element={<Training />} />
|
| 46 |
+
<Route path="/inference" element={<Inference />} />
|
| 47 |
+
<Route path="/calibration" element={<Calibration />} />
|
| 48 |
+
<Route path="/edit-dataset" element={<EditDataset />} />
|
| 49 |
+
|
| 50 |
+
<Route path="*" element={<NotFound />} />
|
| 51 |
+
</Routes>
|
| 52 |
+
</SingleTabGuard>
|
| 53 |
+
<Toaster />
|
| 54 |
+
</BrowserRouter>
|
| 55 |
+
</DragAndDropProvider>
|
| 56 |
+
</UrdfProvider>
|
| 57 |
+
</HfAuthProvider>
|
| 58 |
+
</ApiProvider>
|
| 59 |
+
</ThemeProvider>
|
| 60 |
+
</TooltipProvider>
|
| 61 |
+
</QueryClientProvider>
|
| 62 |
+
);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
export default App;
|
src/components/Footer.tsx
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import { Github, BookOpen } from "lucide-react";
|
| 3 |
+
|
| 4 |
+
const DiscordIcon: React.FC<{ className?: string }> = ({ className }) => (
|
| 5 |
+
<svg
|
| 6 |
+
role="img"
|
| 7 |
+
viewBox="0 0 24 24"
|
| 8 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 9 |
+
fill="currentColor"
|
| 10 |
+
className={className}
|
| 11 |
+
>
|
| 12 |
+
<path d="M20.317 4.369A19.79 19.79 0 0 0 16.558 3.2a.07.07 0 0 0-.074.035c-.211.375-.444.864-.608 1.249a18.27 18.27 0 0 0-5.487 0 12.51 12.51 0 0 0-.617-1.249.077.077 0 0 0-.074-.035 19.736 19.736 0 0 0-3.76 1.169.07.07 0 0 0-.032.027C2.533 8.046 1.79 11.624 2.155 15.157a.082.082 0 0 0 .031.056 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.027c.462-.63.873-1.295 1.226-1.994a.076.076 0 0 0-.041-.105 13.13 13.13 0 0 1-1.873-.892.077.077 0 0 1-.008-.128c.126-.094.252-.192.372-.291a.074.074 0 0 1 .077-.01c3.927 1.793 8.18 1.793 12.061 0a.074.074 0 0 1 .078.009c.12.099.246.198.373.292a.077.077 0 0 1-.006.128 12.32 12.32 0 0 1-1.873.891.077.077 0 0 0-.04.106c.36.698.772 1.363 1.225 1.993a.076.076 0 0 0 .084.028 19.84 19.84 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-4.087-.838-7.636-3.548-10.787a.061.061 0 0 0-.031-.028zM8.02 12.997c-1.182 0-2.156-1.085-2.156-2.419 0-1.333.955-2.419 2.156-2.419 1.21 0 2.175 1.095 2.156 2.42 0 1.333-.955 2.418-2.156 2.418zm7.974 0c-1.182 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.175 1.095 2.156 2.42 0 1.333-.946 2.418-2.156 2.418z" />
|
| 13 |
+
</svg>
|
| 14 |
+
);
|
| 15 |
+
|
| 16 |
+
const links = [
|
| 17 |
+
{
|
| 18 |
+
href: "https://github.com/huggingface/lerobot",
|
| 19 |
+
label: "GitHub",
|
| 20 |
+
Icon: Github,
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
href: "https://huggingface.co/docs/lerobot",
|
| 24 |
+
label: "Documentation",
|
| 25 |
+
Icon: BookOpen,
|
| 26 |
+
},
|
| 27 |
+
{
|
| 28 |
+
href: "https://discord.com/invite/s3KuuzsPFb",
|
| 29 |
+
label: "Discord",
|
| 30 |
+
Icon: DiscordIcon,
|
| 31 |
+
},
|
| 32 |
+
];
|
| 33 |
+
|
| 34 |
+
const Footer: React.FC = () => {
|
| 35 |
+
return (
|
| 36 |
+
<footer className="fixed inset-x-0 bottom-0 z-30 border-t border-gray-800 bg-black/95">
|
| 37 |
+
<div className="mx-auto flex max-w-7xl flex-col items-center justify-between gap-3 px-4 py-4 text-sm text-gray-400 sm:flex-row">
|
| 38 |
+
<span>
|
| 39 |
+
Powered by{" "}
|
| 40 |
+
<a
|
| 41 |
+
href="https://github.com/huggingface/lerobot"
|
| 42 |
+
target="_blank"
|
| 43 |
+
rel="noopener noreferrer"
|
| 44 |
+
className="font-medium text-gray-200 hover:text-white"
|
| 45 |
+
>
|
| 46 |
+
LeRobot
|
| 47 |
+
</a>
|
| 48 |
+
</span>
|
| 49 |
+
<nav className="flex items-center gap-4">
|
| 50 |
+
{links.map(({ href, label, Icon }) => (
|
| 51 |
+
<a
|
| 52 |
+
key={label}
|
| 53 |
+
href={href}
|
| 54 |
+
target="_blank"
|
| 55 |
+
rel="noopener noreferrer"
|
| 56 |
+
className="flex items-center gap-1.5 text-gray-400 hover:text-white"
|
| 57 |
+
>
|
| 58 |
+
<Icon className="h-4 w-4" />
|
| 59 |
+
<span>{label}</span>
|
| 60 |
+
</a>
|
| 61 |
+
))}
|
| 62 |
+
</nav>
|
| 63 |
+
</div>
|
| 64 |
+
</footer>
|
| 65 |
+
);
|
| 66 |
+
};
|
| 67 |
+
|
| 68 |
+
export default Footer;
|
src/components/Logo.tsx
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import React from 'react';
|
| 3 |
+
import { cn } from '@/lib/utils';
|
| 4 |
+
|
| 5 |
+
interface LogoProps extends React.HTMLAttributes<HTMLDivElement> {
|
| 6 |
+
iconOnly?: boolean;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
const Logo: React.FC<LogoProps> = ({
|
| 10 |
+
className,
|
| 11 |
+
iconOnly = false
|
| 12 |
+
}) => {
|
| 13 |
+
return <div className={cn("flex items-center gap-2", className)}>
|
| 14 |
+
<img src="/lovable-uploads/5e648747-34b7-4d8f-93fd-4dbd00aeeefc.png" alt="LeLab Logo" className="h-8 w-8" />
|
| 15 |
+
{!iconOnly && <span className="font-bold text-white text-2xl">LeLab</span>}
|
| 16 |
+
</div>;
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
export default Logo;
|
src/components/SingleTabGuard.tsx
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useCallback, useEffect, useRef, useState, ReactNode } from "react";
|
| 2 |
+
import { Button } from "@/components/ui/button";
|
| 3 |
+
|
| 4 |
+
type Peer = { id: string; openedAt: number; lastSeen: number };
|
| 5 |
+
|
| 6 |
+
const CHANNEL = "lelab-tabs-v1";
|
| 7 |
+
const HEARTBEAT_MS = 1000;
|
| 8 |
+
const PEER_TIMEOUT_MS = 3000;
|
| 9 |
+
|
| 10 |
+
const SingleTabGuard = ({ children }: { children: ReactNode }) => {
|
| 11 |
+
const [isPrimary, setIsPrimary] = useState(true);
|
| 12 |
+
const peersRef = useRef<Map<string, Peer>>(new Map());
|
| 13 |
+
const myIdRef = useRef<string>("");
|
| 14 |
+
const myOpenedAtRef = useRef<number>(0);
|
| 15 |
+
const channelRef = useRef<BroadcastChannel | null>(null);
|
| 16 |
+
|
| 17 |
+
const recompute = useCallback(() => {
|
| 18 |
+
const peers = peersRef.current;
|
| 19 |
+
const cutoff = Date.now() - PEER_TIMEOUT_MS;
|
| 20 |
+
for (const [id, peer] of peers) {
|
| 21 |
+
if (peer.lastSeen < cutoff) peers.delete(id);
|
| 22 |
+
}
|
| 23 |
+
let winnerId = myIdRef.current;
|
| 24 |
+
let winnerOpenedAt = myOpenedAtRef.current;
|
| 25 |
+
for (const peer of peers.values()) {
|
| 26 |
+
if (
|
| 27 |
+
peer.openedAt < winnerOpenedAt ||
|
| 28 |
+
(peer.openedAt === winnerOpenedAt && peer.id < winnerId)
|
| 29 |
+
) {
|
| 30 |
+
winnerId = peer.id;
|
| 31 |
+
winnerOpenedAt = peer.openedAt;
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
setIsPrimary(winnerId === myIdRef.current);
|
| 35 |
+
}, []);
|
| 36 |
+
|
| 37 |
+
useEffect(() => {
|
| 38 |
+
if (typeof window === "undefined" || typeof BroadcastChannel === "undefined") {
|
| 39 |
+
return;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
myIdRef.current = crypto.randomUUID();
|
| 43 |
+
myOpenedAtRef.current = Date.now();
|
| 44 |
+
|
| 45 |
+
const channel = new BroadcastChannel(CHANNEL);
|
| 46 |
+
channelRef.current = channel;
|
| 47 |
+
|
| 48 |
+
const send = (type: string) => {
|
| 49 |
+
channel.postMessage({
|
| 50 |
+
type,
|
| 51 |
+
id: myIdRef.current,
|
| 52 |
+
openedAt: myOpenedAtRef.current,
|
| 53 |
+
});
|
| 54 |
+
};
|
| 55 |
+
|
| 56 |
+
channel.onmessage = (e) => {
|
| 57 |
+
const msg = e.data;
|
| 58 |
+
if (!msg || msg.id === myIdRef.current) return;
|
| 59 |
+
const peers = peersRef.current;
|
| 60 |
+
|
| 61 |
+
if (msg.type === "HEARTBEAT") {
|
| 62 |
+
peers.set(msg.id, {
|
| 63 |
+
id: msg.id,
|
| 64 |
+
openedAt: msg.openedAt,
|
| 65 |
+
lastSeen: Date.now(),
|
| 66 |
+
});
|
| 67 |
+
} else if (msg.type === "RELEASE") {
|
| 68 |
+
peers.delete(msg.id);
|
| 69 |
+
} else if (msg.type === "TAKEOVER") {
|
| 70 |
+
peers.set(msg.id, {
|
| 71 |
+
id: msg.id,
|
| 72 |
+
openedAt: msg.openedAt,
|
| 73 |
+
lastSeen: Date.now(),
|
| 74 |
+
});
|
| 75 |
+
// Move ourselves behind the taker so the election flips.
|
| 76 |
+
if (myOpenedAtRef.current <= msg.openedAt) {
|
| 77 |
+
myOpenedAtRef.current = msg.openedAt + 1;
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
recompute();
|
| 81 |
+
};
|
| 82 |
+
|
| 83 |
+
send("HEARTBEAT");
|
| 84 |
+
const interval = setInterval(() => {
|
| 85 |
+
send("HEARTBEAT");
|
| 86 |
+
recompute();
|
| 87 |
+
}, HEARTBEAT_MS);
|
| 88 |
+
|
| 89 |
+
const onUnload = () => send("RELEASE");
|
| 90 |
+
window.addEventListener("beforeunload", onUnload);
|
| 91 |
+
|
| 92 |
+
return () => {
|
| 93 |
+
window.removeEventListener("beforeunload", onUnload);
|
| 94 |
+
clearInterval(interval);
|
| 95 |
+
send("RELEASE");
|
| 96 |
+
channel.close();
|
| 97 |
+
channelRef.current = null;
|
| 98 |
+
};
|
| 99 |
+
}, [recompute]);
|
| 100 |
+
|
| 101 |
+
const takeOver = useCallback(() => {
|
| 102 |
+
myOpenedAtRef.current = 0;
|
| 103 |
+
channelRef.current?.postMessage({
|
| 104 |
+
type: "TAKEOVER",
|
| 105 |
+
id: myIdRef.current,
|
| 106 |
+
openedAt: 0,
|
| 107 |
+
});
|
| 108 |
+
recompute();
|
| 109 |
+
}, [recompute]);
|
| 110 |
+
|
| 111 |
+
return (
|
| 112 |
+
<>
|
| 113 |
+
{children}
|
| 114 |
+
{!isPrimary && (
|
| 115 |
+
<div
|
| 116 |
+
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-sm"
|
| 117 |
+
role="dialog"
|
| 118 |
+
aria-modal="true"
|
| 119 |
+
>
|
| 120 |
+
<div className="mx-4 max-w-md space-y-4 rounded-lg border bg-background p-6 text-center shadow-lg">
|
| 121 |
+
<h2 className="text-lg font-semibold">
|
| 122 |
+
LeLab is already open in another tab
|
| 123 |
+
</h2>
|
| 124 |
+
<p className="text-sm text-muted-foreground">
|
| 125 |
+
Only one tab can control the robot at a time. Switch back to the
|
| 126 |
+
original tab, or take over here — the other tab will lock.
|
| 127 |
+
</p>
|
| 128 |
+
<Button onClick={takeOver}>Use this tab</Button>
|
| 129 |
+
</div>
|
| 130 |
+
</div>
|
| 131 |
+
)}
|
| 132 |
+
</>
|
| 133 |
+
);
|
| 134 |
+
};
|
| 135 |
+
|
| 136 |
+
export default SingleTabGuard;
|
src/components/TeleopStopNotice.tsx
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect } from "react";
|
| 2 |
+
import { useToast } from "@/hooks/use-toast";
|
| 3 |
+
|
| 4 |
+
const FLAG = "lelab:teleop-stopped";
|
| 5 |
+
|
| 6 |
+
/**
|
| 7 |
+
* One-time confirmation that teleoperation was stopped during the previous
|
| 8 |
+
* page's unload (a browser navigation, reload, or tab close from the
|
| 9 |
+
* teleoperation page set a sessionStorage flag, since React cleanup can't run
|
| 10 |
+
* in those cases). On the next fresh load we surface a toast wherever the user
|
| 11 |
+
* landed, then clear the flag. In-app navigation away from teleop toasts
|
| 12 |
+
* directly and never sets the flag, so this never double-fires.
|
| 13 |
+
*/
|
| 14 |
+
const TeleopStopNotice = () => {
|
| 15 |
+
const { toast } = useToast();
|
| 16 |
+
|
| 17 |
+
useEffect(() => {
|
| 18 |
+
let stopped = false;
|
| 19 |
+
try {
|
| 20 |
+
stopped = sessionStorage.getItem(FLAG) === "1";
|
| 21 |
+
if (stopped) sessionStorage.removeItem(FLAG);
|
| 22 |
+
} catch {
|
| 23 |
+
/* sessionStorage unavailable — nothing to show */
|
| 24 |
+
}
|
| 25 |
+
if (stopped) {
|
| 26 |
+
toast({
|
| 27 |
+
title: "Teleoperation stopped",
|
| 28 |
+
description: "The arm was disconnected cleanly when you left the page.",
|
| 29 |
+
});
|
| 30 |
+
}
|
| 31 |
+
}, [toast]);
|
| 32 |
+
|
| 33 |
+
return null;
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
export default TeleopStopNotice;
|
src/components/UpdateNotice.tsx
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from "react";
|
| 2 |
+
import { Loader2, Copy, Sparkles, ChevronRight } from "lucide-react";
|
| 3 |
+
import {
|
| 4 |
+
Dialog,
|
| 5 |
+
DialogContent,
|
| 6 |
+
DialogDescription,
|
| 7 |
+
DialogHeader,
|
| 8 |
+
DialogTitle,
|
| 9 |
+
} from "@/components/ui/dialog";
|
| 10 |
+
import {
|
| 11 |
+
Collapsible,
|
| 12 |
+
CollapsibleContent,
|
| 13 |
+
CollapsibleTrigger,
|
| 14 |
+
} from "@/components/ui/collapsible";
|
| 15 |
+
import { Button } from "@/components/ui/button";
|
| 16 |
+
import { Checkbox } from "@/components/ui/checkbox";
|
| 17 |
+
import { useApi } from "@/contexts/ApiContext";
|
| 18 |
+
import { useToast } from "@/hooks/use-toast";
|
| 19 |
+
import { useUpdateCheck } from "@/hooks/useUpdateCheck";
|
| 20 |
+
|
| 21 |
+
/**
|
| 22 |
+
* App-level popup that notifies the user when a newer LeLab is available on
|
| 23 |
+
* GitHub. Offers a copy-able upgrade command, a best-effort "Update now" button
|
| 24 |
+
* (runs the pip upgrade on the backend), and a "don't ask again" opt-out.
|
| 25 |
+
*/
|
| 26 |
+
const UpdateNotice = () => {
|
| 27 |
+
const { status, open, dismiss } = useUpdateCheck();
|
| 28 |
+
const { baseUrl, fetchWithHeaders } = useApi();
|
| 29 |
+
const { toast } = useToast();
|
| 30 |
+
const [dontAsk, setDontAsk] = useState(false);
|
| 31 |
+
const [updating, setUpdating] = useState(false);
|
| 32 |
+
const [output, setOutput] = useState<string | null>(null);
|
| 33 |
+
|
| 34 |
+
if (!status) return null;
|
| 35 |
+
|
| 36 |
+
const behind =
|
| 37 |
+
typeof status.commits_behind === "number" && status.commits_behind > 0
|
| 38 |
+
? `${status.commits_behind} commit${status.commits_behind === 1 ? "" : "s"} behind`
|
| 39 |
+
: "A new version is available";
|
| 40 |
+
|
| 41 |
+
const copyCommand = async () => {
|
| 42 |
+
if (!status.update_command) return;
|
| 43 |
+
try {
|
| 44 |
+
await navigator.clipboard.writeText(status.update_command);
|
| 45 |
+
toast({
|
| 46 |
+
title: "Copied",
|
| 47 |
+
description: "Update command copied to clipboard.",
|
| 48 |
+
});
|
| 49 |
+
} catch {
|
| 50 |
+
toast({
|
| 51 |
+
title: "Copy failed",
|
| 52 |
+
description: "Select and copy the command manually.",
|
| 53 |
+
variant: "destructive",
|
| 54 |
+
});
|
| 55 |
+
}
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
const runUpdate = async () => {
|
| 59 |
+
setUpdating(true);
|
| 60 |
+
setOutput(null);
|
| 61 |
+
try {
|
| 62 |
+
const r = await fetchWithHeaders(`${baseUrl}/system/update`, {
|
| 63 |
+
method: "POST",
|
| 64 |
+
});
|
| 65 |
+
const body: { success: boolean; message: string; output: string } =
|
| 66 |
+
await r.json();
|
| 67 |
+
if (body.success) {
|
| 68 |
+
toast({ title: "Updated", description: body.message });
|
| 69 |
+
dismiss(false);
|
| 70 |
+
} else {
|
| 71 |
+
setOutput(body.output || body.message);
|
| 72 |
+
toast({
|
| 73 |
+
title: "Update failed",
|
| 74 |
+
description: body.message,
|
| 75 |
+
variant: "destructive",
|
| 76 |
+
});
|
| 77 |
+
}
|
| 78 |
+
} catch (e) {
|
| 79 |
+
toast({
|
| 80 |
+
title: "Update failed",
|
| 81 |
+
description: e instanceof Error ? e.message : String(e),
|
| 82 |
+
variant: "destructive",
|
| 83 |
+
});
|
| 84 |
+
} finally {
|
| 85 |
+
setUpdating(false);
|
| 86 |
+
}
|
| 87 |
+
};
|
| 88 |
+
|
| 89 |
+
return (
|
| 90 |
+
<Dialog
|
| 91 |
+
open={open}
|
| 92 |
+
onOpenChange={(o) => {
|
| 93 |
+
if (!o && !updating) dismiss(dontAsk);
|
| 94 |
+
}}
|
| 95 |
+
>
|
| 96 |
+
<DialogContent
|
| 97 |
+
className="bg-slate-800 border-slate-700 text-white max-w-lg"
|
| 98 |
+
onOpenAutoFocus={(e) => e.preventDefault()}
|
| 99 |
+
>
|
| 100 |
+
<DialogHeader>
|
| 101 |
+
<DialogTitle className="flex items-center gap-3 text-white">
|
| 102 |
+
<Sparkles className="w-5 h-5 text-amber-400" />
|
| 103 |
+
LeLab update available
|
| 104 |
+
</DialogTitle>
|
| 105 |
+
<DialogDescription className="text-slate-300">
|
| 106 |
+
You're {behind} 😱.
|
| 107 |
+
<br />
|
| 108 |
+
Update to get the latest fixes and features 🤗.
|
| 109 |
+
{status.compare_url && (
|
| 110 |
+
<>
|
| 111 |
+
{" "}
|
| 112 |
+
<a
|
| 113 |
+
href={status.compare_url}
|
| 114 |
+
target="_blank"
|
| 115 |
+
rel="noreferrer"
|
| 116 |
+
className="text-sky-300 underline hover:text-sky-200"
|
| 117 |
+
>
|
| 118 |
+
See what changed
|
| 119 |
+
</a>
|
| 120 |
+
.
|
| 121 |
+
</>
|
| 122 |
+
)}
|
| 123 |
+
</DialogDescription>
|
| 124 |
+
</DialogHeader>
|
| 125 |
+
|
| 126 |
+
<div className="space-y-4">
|
| 127 |
+
<Collapsible>
|
| 128 |
+
<CollapsibleTrigger className="group flex items-center gap-1.5 text-xs font-medium text-slate-300 hover:text-white transition-colors">
|
| 129 |
+
<ChevronRight className="w-3.5 h-3.5 transition-transform group-data-[state=open]:rotate-90" />
|
| 130 |
+
Or update manually
|
| 131 |
+
</CollapsibleTrigger>
|
| 132 |
+
<CollapsibleContent className="pt-2">
|
| 133 |
+
<div className="flex items-start gap-2">
|
| 134 |
+
<code className="min-w-0 flex-1 px-2 py-1.5 rounded bg-slate-900 text-sky-300 text-xs break-all whitespace-pre-wrap">
|
| 135 |
+
{status.update_command}
|
| 136 |
+
</code>
|
| 137 |
+
<Button
|
| 138 |
+
variant="outline"
|
| 139 |
+
size="icon"
|
| 140 |
+
onClick={copyCommand}
|
| 141 |
+
title="Copy command"
|
| 142 |
+
className="shrink-0 bg-slate-900 border-slate-600 text-white hover:bg-slate-700"
|
| 143 |
+
>
|
| 144 |
+
<Copy className="w-4 h-4" />
|
| 145 |
+
</Button>
|
| 146 |
+
</div>
|
| 147 |
+
</CollapsibleContent>
|
| 148 |
+
</Collapsible>
|
| 149 |
+
|
| 150 |
+
{output && (
|
| 151 |
+
<pre className="max-h-40 overflow-auto rounded bg-slate-900 p-2 text-xs text-slate-300 whitespace-pre-wrap">
|
| 152 |
+
{output}
|
| 153 |
+
</pre>
|
| 154 |
+
)}
|
| 155 |
+
|
| 156 |
+
<div className="flex items-center justify-between gap-3 pt-1">
|
| 157 |
+
<label className="flex items-center gap-2 text-sm text-slate-300">
|
| 158 |
+
<Checkbox
|
| 159 |
+
checked={dontAsk}
|
| 160 |
+
onCheckedChange={(v) => setDontAsk(v === true)}
|
| 161 |
+
className="border-slate-300 data-[state=checked]:bg-slate-300 data-[state=checked]:text-slate-900"
|
| 162 |
+
/>
|
| 163 |
+
Don't ask me again
|
| 164 |
+
</label>
|
| 165 |
+
<div className="flex items-center gap-2">
|
| 166 |
+
<Button
|
| 167 |
+
variant="ghost"
|
| 168 |
+
onClick={() => dismiss(dontAsk)}
|
| 169 |
+
disabled={updating}
|
| 170 |
+
className="text-slate-300 hover:bg-slate-700 hover:text-white"
|
| 171 |
+
>
|
| 172 |
+
Later
|
| 173 |
+
</Button>
|
| 174 |
+
{status.can_auto_update && (
|
| 175 |
+
<Button onClick={runUpdate} disabled={updating}>
|
| 176 |
+
{updating ? (
|
| 177 |
+
<>
|
| 178 |
+
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
| 179 |
+
Updating…
|
| 180 |
+
</>
|
| 181 |
+
) : (
|
| 182 |
+
"Update now"
|
| 183 |
+
)}
|
| 184 |
+
</Button>
|
| 185 |
+
)}
|
| 186 |
+
</div>
|
| 187 |
+
</div>
|
| 188 |
+
</div>
|
| 189 |
+
</DialogContent>
|
| 190 |
+
</Dialog>
|
| 191 |
+
);
|
| 192 |
+
};
|
| 193 |
+
|
| 194 |
+
export default UpdateNotice;
|
src/components/UrdfViewer.tsx
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, {
|
| 2 |
+
useEffect,
|
| 3 |
+
useRef,
|
| 4 |
+
useState,
|
| 5 |
+
useMemo,
|
| 6 |
+
useCallback,
|
| 7 |
+
memo,
|
| 8 |
+
} from "react";
|
| 9 |
+
import { cn } from "@/lib/utils";
|
| 10 |
+
|
| 11 |
+
import URDFManipulator from "urdf-loader/src/urdf-manipulator-element.js";
|
| 12 |
+
import { useUrdf } from "@/hooks/useUrdf";
|
| 13 |
+
import { useRealTimeJoints } from "@/hooks/useRealTimeJoints";
|
| 14 |
+
import {
|
| 15 |
+
createUrdfViewer,
|
| 16 |
+
setupMeshLoader,
|
| 17 |
+
setupJointHighlighting,
|
| 18 |
+
setupModelLoading,
|
| 19 |
+
URDFViewerElement,
|
| 20 |
+
} from "@/lib/urdfViewerHelpers";
|
| 21 |
+
|
| 22 |
+
// Register the URDFManipulator as a custom element if it hasn't been already
|
| 23 |
+
if (typeof window !== "undefined" && !customElements.get("urdf-viewer")) {
|
| 24 |
+
customElements.define("urdf-viewer", URDFManipulator);
|
| 25 |
+
}
|
| 26 |
+
import * as THREE from "three";
|
| 27 |
+
|
| 28 |
+
// Extend the interface for the URDF viewer element to include background property
|
| 29 |
+
interface UrdfViewerElement extends HTMLElement {
|
| 30 |
+
background?: string;
|
| 31 |
+
setJointValue?: (jointName: string, value: number) => void;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
const UrdfViewer: React.FC = () => {
|
| 35 |
+
const containerRef = useRef<HTMLDivElement>(null);
|
| 36 |
+
const [highlightedJoint, setHighlightedJoint] = useState<string | null>(null);
|
| 37 |
+
const { registerUrdfProcessor, alternativeUrdfModels, isDefaultModel } =
|
| 38 |
+
useUrdf();
|
| 39 |
+
|
| 40 |
+
const cleanupAnimationRef = useRef<(() => void) | null>(null);
|
| 41 |
+
const viewerRef = useRef<URDFViewerElement | null>(null);
|
| 42 |
+
const hasInitializedRef = useRef<boolean>(false);
|
| 43 |
+
|
| 44 |
+
// Real-time joint updates via WebSocket
|
| 45 |
+
const { isConnected: isWebSocketConnected } = useRealTimeJoints({
|
| 46 |
+
viewerRef,
|
| 47 |
+
enabled: isDefaultModel, // Only enable WebSocket for default model
|
| 48 |
+
});
|
| 49 |
+
|
| 50 |
+
// Add state for custom URDF path
|
| 51 |
+
const [customUrdfPath, setCustomUrdfPath] = useState<string | null>(null);
|
| 52 |
+
const [urlModifierFunc, setUrlModifierFunc] = useState<
|
| 53 |
+
((url: string) => string) | null
|
| 54 |
+
>(null);
|
| 55 |
+
|
| 56 |
+
const packageRef = useRef<string>("");
|
| 57 |
+
|
| 58 |
+
// Implement UrdfProcessor interface for drag and drop
|
| 59 |
+
const urdfProcessor = useMemo(
|
| 60 |
+
() => ({
|
| 61 |
+
loadUrdf: (urdfPath: string) => {
|
| 62 |
+
setCustomUrdfPath(urdfPath);
|
| 63 |
+
},
|
| 64 |
+
setUrlModifierFunc: (func: (url: string) => string) => {
|
| 65 |
+
setUrlModifierFunc(() => func);
|
| 66 |
+
},
|
| 67 |
+
getPackage: () => {
|
| 68 |
+
return packageRef.current;
|
| 69 |
+
},
|
| 70 |
+
}),
|
| 71 |
+
[]
|
| 72 |
+
);
|
| 73 |
+
|
| 74 |
+
// Register the URDF processor with the global drag and drop context
|
| 75 |
+
useEffect(() => {
|
| 76 |
+
registerUrdfProcessor(urdfProcessor);
|
| 77 |
+
}, [registerUrdfProcessor, urdfProcessor]);
|
| 78 |
+
|
| 79 |
+
// Create URL modifier function for default model
|
| 80 |
+
const defaultUrlModifier = useCallback((url: string) => {
|
| 81 |
+
console.log(`🔗 defaultUrlModifier called with: ${url}`);
|
| 82 |
+
|
| 83 |
+
// Handle various package:// URL formats for the default SO-101 model
|
| 84 |
+
if (url.startsWith("package://so_arm_description/meshes/")) {
|
| 85 |
+
const modifiedUrl = url.replace(
|
| 86 |
+
"package://so_arm_description/meshes/",
|
| 87 |
+
"/so-101-urdf/meshes/"
|
| 88 |
+
);
|
| 89 |
+
console.log(`🔗 Modified URL (package): ${modifiedUrl}`);
|
| 90 |
+
return modifiedUrl;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
// Handle case where package path might be partially resolved
|
| 94 |
+
if (url.includes("so_arm_description/meshes/")) {
|
| 95 |
+
const modifiedUrl = url.replace(
|
| 96 |
+
/.*so_arm_description\/meshes\//,
|
| 97 |
+
"/so-101-urdf/meshes/"
|
| 98 |
+
);
|
| 99 |
+
console.log(`🔗 Modified URL (partial): ${modifiedUrl}`);
|
| 100 |
+
return modifiedUrl;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
// Handle the specific problematic path pattern we're seeing in logs
|
| 104 |
+
if (url.includes("/so-101-urdf/so_arm_description/meshes/")) {
|
| 105 |
+
const modifiedUrl = url.replace(
|
| 106 |
+
"/so-101-urdf/so_arm_description/meshes/",
|
| 107 |
+
"/so-101-urdf/meshes/"
|
| 108 |
+
);
|
| 109 |
+
console.log(`🔗 Modified URL (problematic path): ${modifiedUrl}`);
|
| 110 |
+
return modifiedUrl;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
// Handle relative paths that might need mesh folder prefix
|
| 114 |
+
if (
|
| 115 |
+
url.endsWith(".stl") &&
|
| 116 |
+
!url.startsWith("/") &&
|
| 117 |
+
!url.startsWith("http")
|
| 118 |
+
) {
|
| 119 |
+
const modifiedUrl = `/so-101-urdf/meshes/${url}`;
|
| 120 |
+
console.log(`🔗 Modified URL (relative): ${modifiedUrl}`);
|
| 121 |
+
return modifiedUrl;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
console.log(`🔗 Unmodified URL: ${url}`);
|
| 125 |
+
return url;
|
| 126 |
+
}, []);
|
| 127 |
+
|
| 128 |
+
// Main effect to create and setup the viewer only once
|
| 129 |
+
useEffect(() => {
|
| 130 |
+
if (!containerRef.current) return;
|
| 131 |
+
|
| 132 |
+
// Create and configure the URDF viewer element
|
| 133 |
+
const viewer = createUrdfViewer(containerRef.current, true);
|
| 134 |
+
viewerRef.current = viewer; // Store reference to the viewer
|
| 135 |
+
|
| 136 |
+
// Setup mesh loading function with appropriate URL modifier
|
| 137 |
+
const activeUrlModifier = isDefaultModel
|
| 138 |
+
? defaultUrlModifier
|
| 139 |
+
: urlModifierFunc;
|
| 140 |
+
setupMeshLoader(viewer, activeUrlModifier);
|
| 141 |
+
|
| 142 |
+
// Determine which URDF to load - fixed path to match the actual available file
|
| 143 |
+
const urdfPath = isDefaultModel
|
| 144 |
+
? "/so-101-urdf/urdf/so101_new_calib.urdf"
|
| 145 |
+
: customUrdfPath || "";
|
| 146 |
+
|
| 147 |
+
// Set the package path for the default model
|
| 148 |
+
if (isDefaultModel) {
|
| 149 |
+
packageRef.current = "/"; // Set to root so we can handle full path resolution in URL modifier
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
// Setup model loading if a path is available
|
| 153 |
+
let cleanupModelLoading = () => {};
|
| 154 |
+
if (urdfPath) {
|
| 155 |
+
cleanupModelLoading = setupModelLoading(
|
| 156 |
+
viewer,
|
| 157 |
+
urdfPath,
|
| 158 |
+
packageRef.current,
|
| 159 |
+
setCustomUrdfPath,
|
| 160 |
+
alternativeUrdfModels
|
| 161 |
+
);
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
// Setup joint highlighting
|
| 165 |
+
const cleanupJointHighlighting = setupJointHighlighting(
|
| 166 |
+
viewer,
|
| 167 |
+
setHighlightedJoint
|
| 168 |
+
);
|
| 169 |
+
|
| 170 |
+
// Function to fit the robot to the camera view
|
| 171 |
+
const fitRobotToView = (viewer: URDFViewerElement) => {
|
| 172 |
+
if (!viewer || !viewer.robot) {
|
| 173 |
+
console.log(
|
| 174 |
+
"[RobotViewer] Cannot fit to view: No viewer or robot available"
|
| 175 |
+
);
|
| 176 |
+
return;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
try {
|
| 180 |
+
// Create a bounding box for the robot
|
| 181 |
+
const boundingBox = new THREE.Box3().setFromObject(viewer.robot);
|
| 182 |
+
|
| 183 |
+
// Calculate the center of the bounding box
|
| 184 |
+
const center = new THREE.Vector3();
|
| 185 |
+
boundingBox.getCenter(center);
|
| 186 |
+
|
| 187 |
+
// Calculate the size of the bounding box
|
| 188 |
+
const size = new THREE.Vector3();
|
| 189 |
+
boundingBox.getSize(size);
|
| 190 |
+
|
| 191 |
+
// Get the maximum dimension to ensure the entire robot is visible
|
| 192 |
+
const maxDim = Math.max(size.x, size.y, size.z);
|
| 193 |
+
|
| 194 |
+
// Position camera to see the center of the model
|
| 195 |
+
viewer.camera.position.copy(center);
|
| 196 |
+
|
| 197 |
+
// Move the camera back to see the entire robot
|
| 198 |
+
// Use the model's up direction to determine which axis to move along
|
| 199 |
+
const upVector = new THREE.Vector3();
|
| 200 |
+
if (viewer.up === "+Z" || viewer.up === "Z") {
|
| 201 |
+
upVector.set(1, 1, 1); // Move back in a diagonal
|
| 202 |
+
} else if (viewer.up === "+Y" || viewer.up === "Y") {
|
| 203 |
+
upVector.set(1, 1, 1); // Move back in a diagonal
|
| 204 |
+
} else {
|
| 205 |
+
upVector.set(1, 1, 1); // Default direction
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
// Normalize the vector and multiply by the size
|
| 209 |
+
upVector.normalize().multiplyScalar(maxDim * 1.3);
|
| 210 |
+
viewer.camera.position.add(upVector);
|
| 211 |
+
|
| 212 |
+
// Make the camera look at the center of the model
|
| 213 |
+
viewer.controls.target.copy(center);
|
| 214 |
+
|
| 215 |
+
// Update controls and mark for redraw
|
| 216 |
+
viewer.controls.update();
|
| 217 |
+
viewer.redraw();
|
| 218 |
+
|
| 219 |
+
console.log("[RobotViewer] Robot auto-fitted to view");
|
| 220 |
+
} catch (error) {
|
| 221 |
+
console.error("[RobotViewer] Error fitting robot to view:", error);
|
| 222 |
+
}
|
| 223 |
+
};
|
| 224 |
+
|
| 225 |
+
// Add event listener for when the robot is loaded to auto-fit to view
|
| 226 |
+
const onRobotLoad = () => {
|
| 227 |
+
fitRobotToView(viewer);
|
| 228 |
+
};
|
| 229 |
+
|
| 230 |
+
// Setup animation event handler for the default model or when hasAnimation is true
|
| 231 |
+
const onModelProcessed = () => {
|
| 232 |
+
hasInitializedRef.current = true;
|
| 233 |
+
if ("setJointValue" in viewer) {
|
| 234 |
+
// Clear any existing animation
|
| 235 |
+
if (cleanupAnimationRef.current) {
|
| 236 |
+
cleanupAnimationRef.current();
|
| 237 |
+
cleanupAnimationRef.current = null;
|
| 238 |
+
}
|
| 239 |
+
}
|
| 240 |
+
// Auto-fit the robot to view when the model is processed
|
| 241 |
+
onRobotLoad();
|
| 242 |
+
};
|
| 243 |
+
|
| 244 |
+
viewer.addEventListener("urdf-processed", onModelProcessed);
|
| 245 |
+
|
| 246 |
+
// Return cleanup function
|
| 247 |
+
return () => {
|
| 248 |
+
if (cleanupAnimationRef.current) {
|
| 249 |
+
cleanupAnimationRef.current();
|
| 250 |
+
cleanupAnimationRef.current = null;
|
| 251 |
+
}
|
| 252 |
+
hasInitializedRef.current = false;
|
| 253 |
+
cleanupJointHighlighting();
|
| 254 |
+
cleanupModelLoading();
|
| 255 |
+
viewer.removeEventListener("urdf-processed", onModelProcessed);
|
| 256 |
+
};
|
| 257 |
+
}, [
|
| 258 |
+
isDefaultModel,
|
| 259 |
+
customUrdfPath,
|
| 260 |
+
urlModifierFunc,
|
| 261 |
+
defaultUrlModifier,
|
| 262 |
+
alternativeUrdfModels,
|
| 263 |
+
]);
|
| 264 |
+
|
| 265 |
+
return (
|
| 266 |
+
<div
|
| 267 |
+
className={cn(
|
| 268 |
+
"w-full h-full transition-all duration-300 ease-in-out relative",
|
| 269 |
+
"bg-gradient-to-br from-gray-900 to-gray-800"
|
| 270 |
+
)}
|
| 271 |
+
>
|
| 272 |
+
<div ref={containerRef} className="w-full h-full" />
|
| 273 |
+
|
| 274 |
+
{/* Joint highlight indicator */}
|
| 275 |
+
{highlightedJoint && (
|
| 276 |
+
<div className="absolute bottom-4 right-4 bg-black/70 text-white px-3 py-2 rounded-md text-sm font-mono z-10">
|
| 277 |
+
Joint: {highlightedJoint}
|
| 278 |
+
</div>
|
| 279 |
+
)}
|
| 280 |
+
|
| 281 |
+
{/* WebSocket connection status */}
|
| 282 |
+
{isDefaultModel && (
|
| 283 |
+
<div className="absolute top-4 right-4 z-10">
|
| 284 |
+
<div
|
| 285 |
+
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-mono ${
|
| 286 |
+
isWebSocketConnected
|
| 287 |
+
? "bg-green-900/70 text-green-300"
|
| 288 |
+
: "bg-red-900/70 text-red-300"
|
| 289 |
+
}`}
|
| 290 |
+
>
|
| 291 |
+
<div
|
| 292 |
+
className={`w-2 h-2 rounded-full ${
|
| 293 |
+
isWebSocketConnected ? "bg-green-400" : "bg-red-400"
|
| 294 |
+
}`}
|
| 295 |
+
/>
|
| 296 |
+
{isWebSocketConnected ? "Live Robot Data" : "Disconnected"}
|
| 297 |
+
</div>
|
| 298 |
+
</div>
|
| 299 |
+
)}
|
| 300 |
+
</div>
|
| 301 |
+
);
|
| 302 |
+
};
|
| 303 |
+
|
| 304 |
+
export default memo(UrdfViewer);
|
src/components/control/CameraFeed.tsx
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import { VideoOff } from "lucide-react";
|
| 3 |
+
import { useCameraStream } from "@/hooks/useCameraStream";
|
| 4 |
+
|
| 5 |
+
interface CameraFeedProps {
|
| 6 |
+
/** Browser deviceId to stream. Empty string renders the "no camera" state. */
|
| 7 |
+
deviceId: string;
|
| 8 |
+
/** Optional caption shown under the feed. */
|
| 9 |
+
label?: string;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
/** Live browser-camera feed bound to a deviceId via getUserMedia. */
|
| 13 |
+
const CameraFeed: React.FC<CameraFeedProps> = ({ deviceId, label }) => {
|
| 14 |
+
const { videoRef, hasError } = useCameraStream(deviceId, false);
|
| 15 |
+
const showVideo = deviceId && !hasError;
|
| 16 |
+
|
| 17 |
+
return (
|
| 18 |
+
<div className="bg-gray-900 rounded-lg border border-gray-700 overflow-hidden">
|
| 19 |
+
<div className="aspect-[4/3] bg-gray-800 relative">
|
| 20 |
+
{showVideo ? (
|
| 21 |
+
<video
|
| 22 |
+
ref={videoRef}
|
| 23 |
+
autoPlay
|
| 24 |
+
muted
|
| 25 |
+
playsInline
|
| 26 |
+
className="w-full h-full object-cover"
|
| 27 |
+
/>
|
| 28 |
+
) : (
|
| 29 |
+
<div className="w-full h-full flex flex-col items-center justify-center">
|
| 30 |
+
<VideoOff className="w-8 h-8 text-gray-500 mb-2" />
|
| 31 |
+
<span className="text-gray-500 text-sm">
|
| 32 |
+
{deviceId ? "Preview failed" : "No camera selected"}
|
| 33 |
+
</span>
|
| 34 |
+
</div>
|
| 35 |
+
)}
|
| 36 |
+
</div>
|
| 37 |
+
{label && (
|
| 38 |
+
<div className="p-2 text-sm text-gray-300 truncate border-t border-gray-800">
|
| 39 |
+
{label}
|
| 40 |
+
</div>
|
| 41 |
+
)}
|
| 42 |
+
</div>
|
| 43 |
+
);
|
| 44 |
+
};
|
| 45 |
+
|
| 46 |
+
export default CameraFeed;
|
src/components/control/CommandBar.tsx
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import React from 'react';
|
| 3 |
+
import { Mic, MicOff, Send, Camera } from 'lucide-react';
|
| 4 |
+
import { Button } from '@/components/ui/button';
|
| 5 |
+
import { Input } from '@/components/ui/input';
|
| 6 |
+
|
| 7 |
+
interface CommandBarProps {
|
| 8 |
+
command: string;
|
| 9 |
+
setCommand: (command: string) => void;
|
| 10 |
+
handleSendCommand: () => void;
|
| 11 |
+
isVoiceActive: boolean;
|
| 12 |
+
setIsVoiceActive: (isActive: boolean) => void;
|
| 13 |
+
showCamera: boolean;
|
| 14 |
+
setShowCamera: (show: boolean) => void;
|
| 15 |
+
handleEndSession: () => void;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
const CommandBar: React.FC<CommandBarProps> = ({
|
| 19 |
+
command,
|
| 20 |
+
setCommand,
|
| 21 |
+
handleSendCommand,
|
| 22 |
+
isVoiceActive,
|
| 23 |
+
setIsVoiceActive,
|
| 24 |
+
showCamera,
|
| 25 |
+
setShowCamera,
|
| 26 |
+
handleEndSession
|
| 27 |
+
}) => {
|
| 28 |
+
return (
|
| 29 |
+
<div className="bg-gray-900 p-4 space-y-4">
|
| 30 |
+
<div className="flex flex-col sm:flex-row gap-4 items-center max-w-4xl mx-auto w-full">
|
| 31 |
+
<Input
|
| 32 |
+
value={command}
|
| 33 |
+
onChange={(e) => setCommand(e.target.value)}
|
| 34 |
+
placeholder="Tell the robot what to do..."
|
| 35 |
+
className="flex-1 bg-gray-800 border-gray-600 text-white placeholder-gray-400 text-lg py-3"
|
| 36 |
+
onKeyPress={(e) => e.key === 'Enter' && handleSendCommand()}
|
| 37 |
+
/>
|
| 38 |
+
<Button
|
| 39 |
+
onClick={handleSendCommand}
|
| 40 |
+
className="bg-orange-500 hover:bg-orange-600 px-6 py-3 self-stretch sm:self-auto"
|
| 41 |
+
>
|
| 42 |
+
<Send strokeWidth={1.5} />
|
| 43 |
+
Send
|
| 44 |
+
</Button>
|
| 45 |
+
</div>
|
| 46 |
+
|
| 47 |
+
<div className="flex justify-center items-center gap-6">
|
| 48 |
+
<div className="flex flex-wrap justify-center gap-2 sm:gap-4">
|
| 49 |
+
<Button
|
| 50 |
+
onClick={() => setIsVoiceActive(!isVoiceActive)}
|
| 51 |
+
className={`px-6 py-2 ${
|
| 52 |
+
isVoiceActive ? 'bg-gray-600 text-white hover:bg-gray-500' : 'bg-gray-800 text-gray-300 hover:bg-gray-700'
|
| 53 |
+
}`}
|
| 54 |
+
>
|
| 55 |
+
{isVoiceActive ? <Mic strokeWidth={1.5} /> : <MicOff strokeWidth={1.5} />}
|
| 56 |
+
Voice Command
|
| 57 |
+
</Button>
|
| 58 |
+
|
| 59 |
+
<Button
|
| 60 |
+
onClick={() => setShowCamera(!showCamera)}
|
| 61 |
+
className={`px-6 py-2 ${
|
| 62 |
+
showCamera ? 'bg-gray-600 text-white hover:bg-gray-500' : 'bg-gray-800 text-gray-300 hover:bg-gray-700'
|
| 63 |
+
}`}
|
| 64 |
+
>
|
| 65 |
+
<Camera strokeWidth={1.5} />
|
| 66 |
+
Show Camera
|
| 67 |
+
</Button>
|
| 68 |
+
|
| 69 |
+
<Button
|
| 70 |
+
onClick={handleEndSession}
|
| 71 |
+
className="bg-red-600 hover:bg-red-700 px-6 py-2"
|
| 72 |
+
>
|
| 73 |
+
End Session
|
| 74 |
+
</Button>
|
| 75 |
+
</div>
|
| 76 |
+
</div>
|
| 77 |
+
</div>
|
| 78 |
+
);
|
| 79 |
+
};
|
| 80 |
+
|
| 81 |
+
export default CommandBar;
|
src/components/control/MetricsPanel.tsx
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import React, { useEffect, useRef } from 'react';
|
| 3 |
+
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
| 4 |
+
import { Camera, MicOff } from 'lucide-react';
|
| 5 |
+
|
| 6 |
+
interface MetricsPanelProps {
|
| 7 |
+
activeTab: 'SENSORS' | 'MOTORS';
|
| 8 |
+
setActiveTab: (tab: 'SENSORS' | 'MOTORS') => void;
|
| 9 |
+
sensorData: any[];
|
| 10 |
+
motorData: any[];
|
| 11 |
+
hasPermissions: boolean;
|
| 12 |
+
streamRef: React.RefObject<MediaStream | null>;
|
| 13 |
+
isVoiceActive: boolean;
|
| 14 |
+
micLevel: number;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
const MetricsPanel: React.FC<MetricsPanelProps> = ({
|
| 18 |
+
activeTab,
|
| 19 |
+
setActiveTab,
|
| 20 |
+
sensorData,
|
| 21 |
+
motorData,
|
| 22 |
+
hasPermissions,
|
| 23 |
+
streamRef,
|
| 24 |
+
isVoiceActive,
|
| 25 |
+
micLevel,
|
| 26 |
+
}) => {
|
| 27 |
+
const sensorVideoRef = useRef<HTMLVideoElement>(null);
|
| 28 |
+
|
| 29 |
+
useEffect(() => {
|
| 30 |
+
if (activeTab === 'SENSORS' && hasPermissions && sensorVideoRef.current && streamRef.current) {
|
| 31 |
+
if (sensorVideoRef.current.srcObject !== streamRef.current) {
|
| 32 |
+
sensorVideoRef.current.srcObject = streamRef.current;
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
}, [activeTab, hasPermissions, streamRef]);
|
| 36 |
+
|
| 37 |
+
return (
|
| 38 |
+
<div className="w-full lg:w-1/2 p-2 sm:p-4">
|
| 39 |
+
<div className="bg-gray-900 rounded-lg p-4 h-full flex flex-col">
|
| 40 |
+
{/* Tab Headers */}
|
| 41 |
+
<div className="flex mb-4">
|
| 42 |
+
<button
|
| 43 |
+
onClick={() => setActiveTab('MOTORS')}
|
| 44 |
+
className={`px-6 py-2 rounded-t-lg text-sm sm:text-base ${
|
| 45 |
+
activeTab === 'MOTORS'
|
| 46 |
+
? 'bg-orange-500 text-white'
|
| 47 |
+
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
| 48 |
+
}`}
|
| 49 |
+
>
|
| 50 |
+
MOTORS
|
| 51 |
+
</button>
|
| 52 |
+
<button
|
| 53 |
+
onClick={() => setActiveTab('SENSORS')}
|
| 54 |
+
className={`px-6 py-2 rounded-t-lg ml-2 text-sm sm:text-base ${
|
| 55 |
+
activeTab === 'SENSORS'
|
| 56 |
+
? 'bg-orange-500 text-white'
|
| 57 |
+
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
| 58 |
+
}`}
|
| 59 |
+
>
|
| 60 |
+
SENSORS
|
| 61 |
+
</button>
|
| 62 |
+
</div>
|
| 63 |
+
|
| 64 |
+
{/* Chart Content */}
|
| 65 |
+
<div className="flex-1 overflow-y-auto">
|
| 66 |
+
{activeTab === 'SENSORS' && (
|
| 67 |
+
<div className="space-y-4">
|
| 68 |
+
{/* Webcam Feed */}
|
| 69 |
+
<div className="border border-gray-800 rounded p-2 flex flex-col h-64">
|
| 70 |
+
<h3 className="text-sm text-white font-medium mb-2">Live Camera Feed</h3>
|
| 71 |
+
{hasPermissions ? (
|
| 72 |
+
<div className="flex-1 bg-black rounded overflow-hidden">
|
| 73 |
+
<video
|
| 74 |
+
ref={sensorVideoRef}
|
| 75 |
+
autoPlay
|
| 76 |
+
muted
|
| 77 |
+
playsInline
|
| 78 |
+
className="w-full h-full object-contain"
|
| 79 |
+
/>
|
| 80 |
+
</div>
|
| 81 |
+
) : (
|
| 82 |
+
<div className="flex-1 flex items-center justify-center bg-black rounded">
|
| 83 |
+
<div className="text-center">
|
| 84 |
+
<Camera className="w-12 h-12 mx-auto text-gray-500 mb-2" />
|
| 85 |
+
<p className="text-gray-400">Camera permission not granted.</p>
|
| 86 |
+
</div>
|
| 87 |
+
</div>
|
| 88 |
+
)}
|
| 89 |
+
</div>
|
| 90 |
+
|
| 91 |
+
{/* Mic Detection & Other Sensors */}
|
| 92 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
| 93 |
+
<div className="border border-gray-800 rounded p-2 flex flex-col justify-center min-h-[120px]">
|
| 94 |
+
<h3 className="text-sm text-center text-white font-medium mb-2">Voice Activity</h3>
|
| 95 |
+
{hasPermissions ? (
|
| 96 |
+
<div className="flex-1 flex flex-col items-center justify-center gap-2 text-center">
|
| 97 |
+
<div className="flex items-end h-10 gap-px w-full justify-center">
|
| 98 |
+
{[...Array(15)].map((_, i) => {
|
| 99 |
+
const barIsActive = isVoiceActive && i < (micLevel / 120 * 15);
|
| 100 |
+
return (
|
| 101 |
+
<div
|
| 102 |
+
key={i}
|
| 103 |
+
className={`w-1.5 rounded-full transition-colors duration-75 ${barIsActive ? 'bg-orange-500' : 'bg-gray-700'}`}
|
| 104 |
+
style={{ height: `${(i / 15 * 60) + 20}%` }}
|
| 105 |
+
/>
|
| 106 |
+
);
|
| 107 |
+
})}
|
| 108 |
+
</div>
|
| 109 |
+
<p className="text-xs text-gray-300">
|
| 110 |
+
{isVoiceActive ? "Voice commands active" : "Voice commands muted"}
|
| 111 |
+
</p>
|
| 112 |
+
</div>
|
| 113 |
+
) : (
|
| 114 |
+
<div className="flex-1 flex items-center justify-center bg-black rounded">
|
| 115 |
+
<div className="text-center">
|
| 116 |
+
<MicOff className="w-8 h-8 mx-auto text-gray-500 mb-2" />
|
| 117 |
+
<p className="text-gray-400">Microphone permission not granted.</p>
|
| 118 |
+
</div>
|
| 119 |
+
</div>
|
| 120 |
+
)}
|
| 121 |
+
</div>
|
| 122 |
+
|
| 123 |
+
{/* Sensor Charts */}
|
| 124 |
+
{['sensor3', 'sensor4'].map((sensor, index) => (
|
| 125 |
+
<div key={sensor} className="border border-gray-800 rounded p-2 flex flex-col h-auto min-h-[120px]">
|
| 126 |
+
<h3 className="text-sm text-white font-medium mb-2">Sensor {index + 3}</h3>
|
| 127 |
+
<ResponsiveContainer width="100%" height="90%">
|
| 128 |
+
<LineChart data={sensorData}>
|
| 129 |
+
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
| 130 |
+
<XAxis hide />
|
| 131 |
+
<YAxis fontSize={12} stroke="#9CA3AF" />
|
| 132 |
+
<Tooltip
|
| 133 |
+
contentStyle={{
|
| 134 |
+
backgroundColor: '#1F2937',
|
| 135 |
+
border: '1px solid #374151',
|
| 136 |
+
color: '#fff'
|
| 137 |
+
}}
|
| 138 |
+
/>
|
| 139 |
+
<Line
|
| 140 |
+
type="monotone"
|
| 141 |
+
dataKey={sensor}
|
| 142 |
+
stroke={index % 2 === 1 ? '#ff6b35' : '#ffdd44'}
|
| 143 |
+
strokeWidth={2}
|
| 144 |
+
dot={false}
|
| 145 |
+
/>
|
| 146 |
+
</LineChart>
|
| 147 |
+
</ResponsiveContainer>
|
| 148 |
+
</div>
|
| 149 |
+
))}
|
| 150 |
+
</div>
|
| 151 |
+
</div>
|
| 152 |
+
)}
|
| 153 |
+
|
| 154 |
+
{activeTab === 'MOTORS' && (
|
| 155 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
| 156 |
+
{['motor1', 'motor2', 'motor3', 'motor4', 'motor5', 'motor6'].map((motor, index) => (
|
| 157 |
+
<div key={motor} className="border border-gray-800 rounded p-2 h-40">
|
| 158 |
+
<h3 className="text-sm text-white font-medium mb-2">Motor {index + 1}</h3>
|
| 159 |
+
<ResponsiveContainer width="100%" height="80%">
|
| 160 |
+
<LineChart data={motorData}>
|
| 161 |
+
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
| 162 |
+
<XAxis hide />
|
| 163 |
+
<YAxis fontSize={12} stroke="#9CA3AF" />
|
| 164 |
+
<Tooltip
|
| 165 |
+
contentStyle={{
|
| 166 |
+
backgroundColor: '#1F2937',
|
| 167 |
+
border: '1px solid #374151',
|
| 168 |
+
color: '#fff'
|
| 169 |
+
}}
|
| 170 |
+
/>
|
| 171 |
+
<Line
|
| 172 |
+
type="monotone"
|
| 173 |
+
dataKey={motor}
|
| 174 |
+
stroke={index % 2 === 0 ? '#ff6b35' : '#ffdd44'}
|
| 175 |
+
strokeWidth={2}
|
| 176 |
+
dot={false}
|
| 177 |
+
/>
|
| 178 |
+
</LineChart>
|
| 179 |
+
</ResponsiveContainer>
|
| 180 |
+
</div>
|
| 181 |
+
))}
|
| 182 |
+
</div>
|
| 183 |
+
)}
|
| 184 |
+
</div>
|
| 185 |
+
</div>
|
| 186 |
+
</div>
|
| 187 |
+
);
|
| 188 |
+
};
|
| 189 |
+
|
| 190 |
+
export default MetricsPanel;
|
src/components/control/TeleopCameraPanel.tsx
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from "react";
|
| 2 |
+
import { RefreshCw } from "lucide-react";
|
| 3 |
+
import { Button } from "@/components/ui/button";
|
| 4 |
+
import { Label } from "@/components/ui/label";
|
| 5 |
+
import { Switch } from "@/components/ui/switch";
|
| 6 |
+
import { useRobots } from "@/hooks/useRobots";
|
| 7 |
+
import CameraFeed from "./CameraFeed";
|
| 8 |
+
|
| 9 |
+
/**
|
| 10 |
+
* Optional live camera panel for the teleoperation page. Off by default so we
|
| 11 |
+
* never call getUserMedia just by landing on the page (same consent pattern as
|
| 12 |
+
* the calibration camera toggle). Teleoperation opens no cv2 cameras, so the
|
| 13 |
+
* browser can stream them directly while the arm runs.
|
| 14 |
+
*
|
| 15 |
+
* A strict mirror of the selected robot's configured cameras: one live feed per
|
| 16 |
+
* camera on the robot record (e.g. "wrist_cam", "webcam"), stacked vertically.
|
| 17 |
+
* If the robot has none configured it shows nothing — teleop never surfaces a
|
| 18 |
+
* device that wasn't deliberately added to the robot.
|
| 19 |
+
*/
|
| 20 |
+
const TeleopCameraPanel: React.FC = () => {
|
| 21 |
+
const [enabled, setEnabled] = useState(false);
|
| 22 |
+
// Bumped by the retry button to remount the feeds (a fresh getUserMedia
|
| 23 |
+
// attempt) — useful if a camera was unplugged and reconnected.
|
| 24 |
+
const [reloadKey, setReloadKey] = useState(0);
|
| 25 |
+
const { selectedRecord, isLoading: robotsLoading } = useRobots();
|
| 26 |
+
|
| 27 |
+
// Feeds come solely from the robot's configured cameras; each carries a stored
|
| 28 |
+
// browser device_id we stream directly. A configured camera whose device is
|
| 29 |
+
// currently absent still shows (name + failed-preview placeholder), so the
|
| 30 |
+
// user can tell it's expected but not detected.
|
| 31 |
+
const configured = selectedRecord?.cameras ?? [];
|
| 32 |
+
const feeds = configured.map((c) => ({
|
| 33 |
+
key: c.id,
|
| 34 |
+
name: c.name,
|
| 35 |
+
deviceId: c.device_id,
|
| 36 |
+
}));
|
| 37 |
+
|
| 38 |
+
return (
|
| 39 |
+
<div className="bg-gray-900 rounded-lg p-4 flex flex-col gap-4 h-full">
|
| 40 |
+
<div className="flex items-center justify-between">
|
| 41 |
+
<h2 className="text-xl font-medium text-gray-200">Cameras</h2>
|
| 42 |
+
<div className="flex items-center gap-2">
|
| 43 |
+
{enabled && feeds.length > 0 && (
|
| 44 |
+
<Button
|
| 45 |
+
type="button"
|
| 46 |
+
variant="ghost"
|
| 47 |
+
size="icon"
|
| 48 |
+
onClick={() => setReloadKey((k) => k + 1)}
|
| 49 |
+
className="h-9 w-9 text-gray-400 hover:text-white flex-shrink-0"
|
| 50 |
+
title="Retry camera feeds (e.g. after reconnecting a camera)"
|
| 51 |
+
aria-label="Retry camera feeds"
|
| 52 |
+
>
|
| 53 |
+
<RefreshCw className="w-4 h-4" />
|
| 54 |
+
</Button>
|
| 55 |
+
)}
|
| 56 |
+
<Label htmlFor="teleop-camera-toggle" className="text-sm text-gray-400">
|
| 57 |
+
{enabled ? "On" : "Off"}
|
| 58 |
+
</Label>
|
| 59 |
+
<Switch
|
| 60 |
+
id="teleop-camera-toggle"
|
| 61 |
+
checked={enabled}
|
| 62 |
+
onCheckedChange={setEnabled}
|
| 63 |
+
/>
|
| 64 |
+
</div>
|
| 65 |
+
</div>
|
| 66 |
+
|
| 67 |
+
{enabled ? (
|
| 68 |
+
feeds.length > 0 ? (
|
| 69 |
+
<div className="flex flex-col gap-3 overflow-y-auto">
|
| 70 |
+
{feeds.map((feed) => (
|
| 71 |
+
<CameraFeed
|
| 72 |
+
key={`${feed.key}:${reloadKey}`}
|
| 73 |
+
deviceId={feed.deviceId}
|
| 74 |
+
label={feed.name}
|
| 75 |
+
/>
|
| 76 |
+
))}
|
| 77 |
+
</div>
|
| 78 |
+
) : (
|
| 79 |
+
<p className="text-sm text-gray-500">
|
| 80 |
+
{robotsLoading
|
| 81 |
+
? "Loading robot..."
|
| 82 |
+
: "No cameras configured for this robot. Add them during calibration to see live feeds here."}
|
| 83 |
+
</p>
|
| 84 |
+
)
|
| 85 |
+
) : (
|
| 86 |
+
<p className="text-sm text-gray-500">
|
| 87 |
+
Turn on to watch your cameras while you teleoperate.
|
| 88 |
+
</p>
|
| 89 |
+
)}
|
| 90 |
+
</div>
|
| 91 |
+
);
|
| 92 |
+
};
|
| 93 |
+
|
| 94 |
+
export default TeleopCameraPanel;
|
src/components/control/VisualizerPanel.tsx
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import { Button } from "@/components/ui/button";
|
| 3 |
+
import { ArrowLeft } from "lucide-react";
|
| 4 |
+
import { cn } from "@/lib/utils";
|
| 5 |
+
import UrdfViewer from "../UrdfViewer";
|
| 6 |
+
import Logo from "@/components/Logo";
|
| 7 |
+
|
| 8 |
+
interface VisualizerPanelProps {
|
| 9 |
+
onGoBack: () => void;
|
| 10 |
+
className?: string;
|
| 11 |
+
/** Optional content rendered as a column beside the 3D viewer (e.g. a camera panel). */
|
| 12 |
+
rightSlot?: React.ReactNode;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
const VisualizerPanel: React.FC<VisualizerPanelProps> = ({
|
| 16 |
+
onGoBack,
|
| 17 |
+
className,
|
| 18 |
+
rightSlot,
|
| 19 |
+
}) => {
|
| 20 |
+
return (
|
| 21 |
+
<div
|
| 22 |
+
className={cn(
|
| 23 |
+
"w-full p-2 sm:p-4 space-y-4 lg:space-y-0 lg:space-x-4 flex flex-col lg:flex-row",
|
| 24 |
+
className
|
| 25 |
+
)}
|
| 26 |
+
>
|
| 27 |
+
<div className="bg-gray-900 rounded-lg p-4 flex-1 flex flex-col">
|
| 28 |
+
<div className="flex items-center gap-4 mb-4">
|
| 29 |
+
<Button
|
| 30 |
+
variant="ghost"
|
| 31 |
+
size="icon"
|
| 32 |
+
onClick={onGoBack}
|
| 33 |
+
className="text-gray-400 hover:text-white hover:bg-gray-800 flex-shrink-0"
|
| 34 |
+
>
|
| 35 |
+
<ArrowLeft className="h-5 w-5" />
|
| 36 |
+
</Button>
|
| 37 |
+
<Logo iconOnly={true} />
|
| 38 |
+
<div className="w-px h-6 bg-gray-700" />
|
| 39 |
+
<h2 className="text-xl font-medium text-gray-200">Teleoperation</h2>
|
| 40 |
+
</div>
|
| 41 |
+
<div className="flex-1 bg-black rounded border border-gray-800 min-h-[50vh] lg:min-h-0">
|
| 42 |
+
<UrdfViewer />
|
| 43 |
+
</div>
|
| 44 |
+
</div>
|
| 45 |
+
{rightSlot && (
|
| 46 |
+
<div className="lg:w-96 flex flex-col">{rightSlot}</div>
|
| 47 |
+
)}
|
| 48 |
+
</div>
|
| 49 |
+
);
|
| 50 |
+
};
|
| 51 |
+
|
| 52 |
+
export default VisualizerPanel;
|
src/components/jobs/CheckpointDropdown.tsx
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import {
|
| 3 |
+
Select,
|
| 4 |
+
SelectContent,
|
| 5 |
+
SelectItem,
|
| 6 |
+
SelectTrigger,
|
| 7 |
+
SelectValue,
|
| 8 |
+
} from "@/components/ui/select";
|
| 9 |
+
import { JobCheckpoint } from "@/lib/checkpointsApi";
|
| 10 |
+
|
| 11 |
+
interface Props {
|
| 12 |
+
checkpoints: JobCheckpoint[];
|
| 13 |
+
selectedStep: number | null;
|
| 14 |
+
onChange: (step: number) => void;
|
| 15 |
+
disabled?: boolean;
|
| 16 |
+
placeholder?: string;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export const CheckpointDropdown: React.FC<Props> = ({
|
| 20 |
+
checkpoints,
|
| 21 |
+
selectedStep,
|
| 22 |
+
onChange,
|
| 23 |
+
disabled,
|
| 24 |
+
placeholder = "Select checkpoint",
|
| 25 |
+
}) => {
|
| 26 |
+
// step 0 is the sentinel for an imported single-model checkpoint (lerobot
|
| 27 |
+
// never saves at step 0), so it has no meaningful step number — show
|
| 28 |
+
// "latest" instead. Real training checkpoints keep their step label.
|
| 29 |
+
const labelFor = (step: number) => (step === 0 ? "latest" : `step ${step}`);
|
| 30 |
+
const value = selectedStep != null ? String(selectedStep) : undefined;
|
| 31 |
+
return (
|
| 32 |
+
<Select
|
| 33 |
+
value={value}
|
| 34 |
+
onValueChange={(v) => onChange(Number(v))}
|
| 35 |
+
disabled={disabled || checkpoints.length === 0}
|
| 36 |
+
>
|
| 37 |
+
<SelectTrigger
|
| 38 |
+
className="bg-slate-800 border-slate-700 text-white h-8 text-xs px-2 w-auto min-w-[110px]"
|
| 39 |
+
onClick={(e) => e.stopPropagation()}
|
| 40 |
+
>
|
| 41 |
+
<SelectValue placeholder={placeholder} />
|
| 42 |
+
</SelectTrigger>
|
| 43 |
+
<SelectContent className="bg-slate-900 border-slate-700 text-white">
|
| 44 |
+
{checkpoints.map((c) => (
|
| 45 |
+
<SelectItem
|
| 46 |
+
key={c.step}
|
| 47 |
+
value={String(c.step)}
|
| 48 |
+
onClick={(e) => e.stopPropagation()}
|
| 49 |
+
>
|
| 50 |
+
{labelFor(c.step)}
|
| 51 |
+
</SelectItem>
|
| 52 |
+
))}
|
| 53 |
+
</SelectContent>
|
| 54 |
+
</Select>
|
| 55 |
+
);
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
export default CheckpointDropdown;
|