Spaces:
Running
Running
Mostly UI Update
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .gitattributes +1 -0
- .prettierignore +6 -0
- .prettierrc +1 -1
- DOCKER_README.md +0 -335
- README.md +10 -0
- ROBOT_ARCHITECTURE.md +469 -330
- ROBOT_INSTANCING_README.md +0 -73
- bun.lock +21 -18
- components.json +2 -2
- docker-compose.yml +3 -3
- eslint.config.js +12 -12
- package.json +2 -1
- {src/lib → packages}/feetech.js/README.md +1 -3
- packages/feetech.js/index.d.ts +37 -0
- packages/feetech.js/index.mjs +57 -0
- packages/feetech.js/lowLevelSDK.mjs +1232 -0
- packages/feetech.js/package.json +38 -0
- packages/feetech.js/scsServoSDK.mjs +910 -0
- packages/feetech.js/scsServoSDKUnlock.mjs +217 -0
- {src/lib → packages}/feetech.js/scsservo_constants.mjs +14 -14
- packages/feetech.js/test.html +770 -0
- src-python/README.md +192 -138
- src/app.css +105 -105
- src/lib/components/3d/GridCustom.svelte +379 -0
- src/lib/components/3d/Robot.svelte +493 -40
- src/lib/components/3d/robot/URDF/createRobot.svelte.ts +19 -19
- src/lib/components/3d/robot/URDF/interfaces/IUrdfBox.ts +2 -2
- src/lib/components/3d/robot/URDF/interfaces/IUrdfCylinder.ts +3 -3
- src/lib/components/3d/robot/URDF/interfaces/IUrdfJoint.ts +40 -40
- src/lib/components/3d/robot/URDF/interfaces/IUrdfLink.ts +18 -18
- src/lib/components/3d/robot/URDF/interfaces/IUrdfMesh.ts +4 -4
- src/lib/components/3d/robot/URDF/interfaces/IUrdfRobot.ts +8 -8
- src/lib/components/3d/robot/URDF/interfaces/IUrdfVisual.ts +38 -38
- src/lib/components/3d/robot/URDF/interfaces/index.ts +7 -7
- src/lib/components/3d/robot/URDF/mesh/DAE.svelte +6 -6
- src/lib/components/3d/robot/URDF/mesh/OBJ.svelte +5 -5
- src/lib/components/3d/robot/URDF/mesh/STL.svelte +7 -7
- src/lib/components/3d/robot/URDF/primitives/UrdfJoint.svelte +29 -15
- src/lib/components/3d/robot/URDF/primitives/UrdfLink.svelte +15 -24
- src/lib/components/3d/robot/URDF/primitives/UrdfThree.svelte +11 -11
- src/lib/components/3d/robot/URDF/primitives/UrdfVisual.svelte +20 -32
- src/lib/components/3d/robot/URDF/runes/urdf_state.svelte.ts +106 -106
- src/lib/components/3d/robot/URDF/utils/UrdfParser.ts +446 -448
- src/lib/components/3d/robot/URDF/utils/helper.ts +39 -36
- src/lib/components/ButtonBar.svelte +0 -161
- src/lib/components/ControlsSheet.svelte +0 -76
- src/lib/components/Overlay.svelte +0 -36
- src/lib/components/PanelWrapper.svelte +0 -32
- src/lib/components/RobotStatusDemo.svelte +0 -65
- src/lib/components/SettingsSheet.svelte +0 -109
.gitattributes
CHANGED
@@ -1 +1,2 @@
|
|
1 |
*.stl filter=lfs diff=lfs merge=lfs -text
|
|
|
|
1 |
*.stl filter=lfs diff=lfs merge=lfs -text
|
2 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
.prettierignore
CHANGED
@@ -4,3 +4,9 @@ pnpm-lock.yaml
|
|
4 |
yarn.lock
|
5 |
bun.lock
|
6 |
bun.lockb
|
|
|
|
|
|
|
|
|
|
|
|
|
|
4 |
yarn.lock
|
5 |
bun.lock
|
6 |
bun.lockb
|
7 |
+
|
8 |
+
# Src python
|
9 |
+
src-python/
|
10 |
+
node_modules/
|
11 |
+
build/
|
12 |
+
.svelte-kit/
|
.prettierrc
CHANGED
@@ -1,6 +1,6 @@
|
|
1 |
{
|
2 |
"useTabs": true,
|
3 |
-
"singleQuote":
|
4 |
"trailingComma": "none",
|
5 |
"printWidth": 100,
|
6 |
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
|
|
1 |
{
|
2 |
"useTabs": true,
|
3 |
+
"singleQuote": false,
|
4 |
"trailingComma": "none",
|
5 |
"printWidth": 100,
|
6 |
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
DOCKER_README.md
DELETED
@@ -1,335 +0,0 @@
|
|
1 |
-
# 🐳 LeRobot Arena Docker Setup
|
2 |
-
|
3 |
-
This Docker setup provides a containerized environment that runs both the Python backend and Svelte frontend for LeRobot Arena using modern tools like Bun, uv, and box-packager.
|
4 |
-
|
5 |
-
## 🏗️ Architecture
|
6 |
-
|
7 |
-
The application consists of:
|
8 |
-
- **Svelte Frontend**: Built as static files and served by FastAPI
|
9 |
-
- **FastAPI Backend**: WebSocket server with HTTP API for robot control
|
10 |
-
- **Single Port**: Both frontend and backend served on port 7860
|
11 |
-
|
12 |
-
## 🚀 Quick Start
|
13 |
-
|
14 |
-
### Build and Run with Docker
|
15 |
-
|
16 |
-
```bash
|
17 |
-
# Build the image
|
18 |
-
docker build -t lerobot-arena .
|
19 |
-
|
20 |
-
# Run the container
|
21 |
-
docker run -p 7860:7860 lerobot-arena
|
22 |
-
|
23 |
-
# Access the application
|
24 |
-
open http://localhost:7860
|
25 |
-
```
|
26 |
-
|
27 |
-
### Using Docker Compose
|
28 |
-
|
29 |
-
```bash
|
30 |
-
# Start the application
|
31 |
-
docker-compose up
|
32 |
-
|
33 |
-
# Access the application
|
34 |
-
open http://localhost:7860
|
35 |
-
```
|
36 |
-
|
37 |
-
## 🌐 Deployment
|
38 |
-
|
39 |
-
### Hugging Face Spaces
|
40 |
-
|
41 |
-
This project is configured for deployment on Hugging Face Spaces:
|
42 |
-
|
43 |
-
1. Push to a Hugging Face Space repository
|
44 |
-
2. The Dockerfile will automatically build and serve the application
|
45 |
-
3. Both frontend and backend run on port 7860 (HF Spaces default)
|
46 |
-
4. Environment detection automatically configures URLs for HF Spaces
|
47 |
-
|
48 |
-
### Local Development vs Production
|
49 |
-
|
50 |
-
- **Local Development**: Frontend dev server on 5173, backend on 7860
|
51 |
-
- **Docker/Production**: Both frontend and backend served by FastAPI on 7860
|
52 |
-
- **HF Spaces**: Single FastAPI server on 7860 with automatic HTTPS and domain detection
|
53 |
-
|
54 |
-
## 🔧 Environment Variables
|
55 |
-
|
56 |
-
- `SPACE_HOST`: Set automatically by Hugging Face Spaces for proper URL configuration
|
57 |
-
- `PORT`: Override the default port (7860)
|
58 |
-
|
59 |
-
## 📡 API Endpoints
|
60 |
-
|
61 |
-
- **Frontend**: `http://localhost:7860/` (Served by FastAPI)
|
62 |
-
- **API**: `http://localhost:7860/api/*`
|
63 |
-
- **WebSocket**: `ws://localhost:7860/ws/*`
|
64 |
-
- **Status**: `http://localhost:7860/status`
|
65 |
-
|
66 |
-
## 🛠️ Development
|
67 |
-
|
68 |
-
For local development without Docker:
|
69 |
-
|
70 |
-
```bash
|
71 |
-
# Start backend
|
72 |
-
cd src-python
|
73 |
-
uv sync
|
74 |
-
uv run python start_server.py
|
75 |
-
|
76 |
-
# Start frontend (in another terminal)
|
77 |
-
bun install
|
78 |
-
bun run dev
|
79 |
-
```
|
80 |
-
|
81 |
-
The frontend dev server (port 5173) will automatically proxy API requests to the backend (port 7860).
|
82 |
-
|
83 |
-
## 📋 What's Included
|
84 |
-
|
85 |
-
The Docker container includes:
|
86 |
-
|
87 |
-
1. **Frontend**:
|
88 |
-
- Svelte application built as static files using **Bun**
|
89 |
-
- Served on port 7860 using Python's built-in HTTP server
|
90 |
-
- Production-ready build with all optimizations
|
91 |
-
|
92 |
-
2. **Backend**:
|
93 |
-
- FastAPI Python server with dependencies managed by **uv**
|
94 |
-
- **Standalone executable** created with **box-packager** for faster startup
|
95 |
-
- WebSocket support for real-time robot communication
|
96 |
-
- Runs on port 8080
|
97 |
-
- Auto-configured for container environment
|
98 |
-
|
99 |
-
## 🛠️ Development vs Production
|
100 |
-
|
101 |
-
### Production (Default)
|
102 |
-
The Dockerfile builds the Svelte app as static files using Bun and packages the Python backend as a standalone executable using box-packager.
|
103 |
-
|
104 |
-
### Development
|
105 |
-
For development with hot-reload, you can use local tools:
|
106 |
-
|
107 |
-
```bash
|
108 |
-
# Terminal 1: Frontend development
|
109 |
-
bun run dev
|
110 |
-
|
111 |
-
# Terminal 2: Backend development
|
112 |
-
cd src-python && python start_server.py
|
113 |
-
```
|
114 |
-
|
115 |
-
## 📁 Container Structure
|
116 |
-
|
117 |
-
```
|
118 |
-
/home/user/app/
|
119 |
-
├── src-python/ # Python backend source
|
120 |
-
│ └── target/release/ # Box-packager standalone executable
|
121 |
-
├── static-frontend/ # Built Svelte app (static files)
|
122 |
-
└── start_services.sh # Startup script for both services
|
123 |
-
```
|
124 |
-
|
125 |
-
## 🔧 Build Process
|
126 |
-
|
127 |
-
The Docker build process includes these key steps:
|
128 |
-
|
129 |
-
1. **Frontend Build Stage (Bun)**:
|
130 |
-
- Uses `oven/bun:1-alpine` for fast package management
|
131 |
-
- Builds Svelte app with `bun run build`
|
132 |
-
- Produces optimized static files
|
133 |
-
|
134 |
-
2. **Backend Build Stage (Python + Rust)**:
|
135 |
-
- Installs Rust/Cargo for box-packager
|
136 |
-
- Uses `uv` for fast Python dependency management
|
137 |
-
- Packages backend with `box package` into standalone executable
|
138 |
-
- Configures proper user permissions for HF Spaces
|
139 |
-
|
140 |
-
3. **Runtime**:
|
141 |
-
- Runs standalone executable (faster startup)
|
142 |
-
- Serves frontend static files
|
143 |
-
- Both services managed by startup script
|
144 |
-
|
145 |
-
## 🚀 Performance Benefits
|
146 |
-
|
147 |
-
### Box-packager Advantages:
|
148 |
-
- **Faster startup**: No Python interpreter overhead
|
149 |
-
- **Self-contained**: All dependencies bundled
|
150 |
-
- **Smaller runtime**: No need for full Python environment
|
151 |
-
- **Cross-platform**: Single executable works anywhere
|
152 |
-
|
153 |
-
### Build Optimization:
|
154 |
-
- **Bun**: Faster JavaScript package manager and bundler
|
155 |
-
- **uv**: Ultra-fast Python package manager
|
156 |
-
- **Multi-stage build**: Minimal final image size
|
157 |
-
- **Alpine base**: Lightweight frontend build stage
|
158 |
-
|
159 |
-
## 🔧 Customization
|
160 |
-
|
161 |
-
### Environment Variables
|
162 |
-
|
163 |
-
You can customize the container behavior using environment variables:
|
164 |
-
|
165 |
-
```bash
|
166 |
-
docker run -p 8080:8080 -p 7860:7860 \
|
167 |
-
-e PYTHONUNBUFFERED=1 \
|
168 |
-
-e NODE_ENV=production \
|
169 |
-
lerobot-arena
|
170 |
-
```
|
171 |
-
|
172 |
-
### Port Configuration
|
173 |
-
|
174 |
-
To use different ports:
|
175 |
-
|
176 |
-
```bash
|
177 |
-
# Map to different host ports
|
178 |
-
docker run -p 9000:8080 -p 3000:7860 lerobot-arena
|
179 |
-
```
|
180 |
-
|
181 |
-
Then access:
|
182 |
-
- Frontend: http://localhost:3000
|
183 |
-
- Backend: http://localhost:9000
|
184 |
-
|
185 |
-
### Volume Mounts for Persistence
|
186 |
-
|
187 |
-
```bash
|
188 |
-
# Mount data directory for persistence
|
189 |
-
docker run -p 8080:8080 -p 7860:7860 \
|
190 |
-
-v $(pwd)/data:/home/user/app/data \
|
191 |
-
lerobot-arena
|
192 |
-
```
|
193 |
-
|
194 |
-
## 🐛 Troubleshooting
|
195 |
-
|
196 |
-
### Container won't start
|
197 |
-
```bash
|
198 |
-
# Check logs
|
199 |
-
docker-compose logs lerobot-arena
|
200 |
-
|
201 |
-
# Or for direct docker run
|
202 |
-
docker logs <container-id>
|
203 |
-
```
|
204 |
-
|
205 |
-
### Port conflicts
|
206 |
-
```bash
|
207 |
-
# Check what's using the ports
|
208 |
-
lsof -i :8080
|
209 |
-
lsof -i :7860
|
210 |
-
|
211 |
-
# Kill processes or use different ports
|
212 |
-
docker run -p 8081:8080 -p 7861:7860 lerobot-arena
|
213 |
-
```
|
214 |
-
|
215 |
-
### Frontend not loading
|
216 |
-
```bash
|
217 |
-
# Verify the frontend was built correctly
|
218 |
-
docker exec -it <container-id> ls -la /home/user/app/static-frontend
|
219 |
-
|
220 |
-
# Check if frontend server is running
|
221 |
-
docker exec -it <container-id> ps aux | grep python
|
222 |
-
```
|
223 |
-
|
224 |
-
### Backend API errors
|
225 |
-
```bash
|
226 |
-
# Check if standalone executable is running
|
227 |
-
docker exec -it <container-id> ps aux | grep lerobot-arena-server
|
228 |
-
|
229 |
-
# Test backend directly
|
230 |
-
curl http://localhost:8080/
|
231 |
-
```
|
232 |
-
|
233 |
-
### Box-packager Build Issues
|
234 |
-
```bash
|
235 |
-
# Force rebuild without cache to fix cargo/box issues
|
236 |
-
docker-compose build --no-cache
|
237 |
-
docker-compose up
|
238 |
-
|
239 |
-
# Check if Rust/Cargo is properly installed
|
240 |
-
docker exec -it <container-id> cargo --version
|
241 |
-
```
|
242 |
-
|
243 |
-
## 🔄 Updates and Rebuilding
|
244 |
-
|
245 |
-
```bash
|
246 |
-
# Pull latest code and rebuild
|
247 |
-
git pull
|
248 |
-
docker-compose down
|
249 |
-
docker-compose up --build
|
250 |
-
|
251 |
-
# Force rebuild without cache
|
252 |
-
docker-compose build --no-cache
|
253 |
-
docker-compose up
|
254 |
-
```
|
255 |
-
|
256 |
-
## 🚀 Hugging Face Spaces Deployment
|
257 |
-
|
258 |
-
This Docker setup is optimized for Hugging Face Spaces:
|
259 |
-
|
260 |
-
### Key Features for HF Spaces:
|
261 |
-
- **Port 7860**: Uses the default HF Spaces port
|
262 |
-
- **User permissions**: Runs as user ID 1000 as required
|
263 |
-
- **Proper ownership**: All files owned by the user
|
264 |
-
- **Git support**: Includes git for dependency resolution
|
265 |
-
- **Standalone executable**: Faster cold starts on HF infrastructure
|
266 |
-
|
267 |
-
### Deployment Steps:
|
268 |
-
1. Push your code to a GitHub repository
|
269 |
-
2. Create a new Space on Hugging Face Spaces
|
270 |
-
3. Connect your GitHub repository
|
271 |
-
4. The YAML frontmatter in README.md will auto-configure the Space
|
272 |
-
5. HF will build and deploy your Docker container automatically
|
273 |
-
|
274 |
-
### Accessing on HF Spaces:
|
275 |
-
- Your Space URL will serve the frontend directly
|
276 |
-
- Backend API will be available at your-space-url/api/ (if using a reverse proxy)
|
277 |
-
|
278 |
-
## 🔧 Advanced Configuration
|
279 |
-
|
280 |
-
### Using with nginx (Production)
|
281 |
-
```nginx
|
282 |
-
server {
|
283 |
-
listen 80;
|
284 |
-
|
285 |
-
# Serve frontend
|
286 |
-
location / {
|
287 |
-
proxy_pass http://localhost:7860;
|
288 |
-
}
|
289 |
-
|
290 |
-
# Proxy API calls
|
291 |
-
location /api/ {
|
292 |
-
proxy_pass http://localhost:8080;
|
293 |
-
proxy_http_version 1.1;
|
294 |
-
proxy_set_header Upgrade $http_upgrade;
|
295 |
-
proxy_set_header Connection 'upgrade';
|
296 |
-
proxy_set_header Host $host;
|
297 |
-
proxy_cache_bypass $http_upgrade;
|
298 |
-
}
|
299 |
-
|
300 |
-
# WebSocket support
|
301 |
-
location /ws/ {
|
302 |
-
proxy_pass http://localhost:8080;
|
303 |
-
proxy_http_version 1.1;
|
304 |
-
proxy_set_header Upgrade $http_upgrade;
|
305 |
-
proxy_set_header Connection "upgrade";
|
306 |
-
}
|
307 |
-
}
|
308 |
-
```
|
309 |
-
|
310 |
-
## 📊 Container Stats
|
311 |
-
|
312 |
-
```bash
|
313 |
-
# Monitor resource usage
|
314 |
-
docker stats lerobot-arena
|
315 |
-
|
316 |
-
# View container info
|
317 |
-
docker inspect lerobot-arena
|
318 |
-
```
|
319 |
-
|
320 |
-
## 🧹 Cleanup
|
321 |
-
|
322 |
-
```bash
|
323 |
-
# Stop and remove containers
|
324 |
-
docker-compose down
|
325 |
-
|
326 |
-
# Remove images
|
327 |
-
docker rmi lerobot-arena
|
328 |
-
|
329 |
-
# Clean up unused images and containers
|
330 |
-
docker system prune -a
|
331 |
-
```
|
332 |
-
|
333 |
-
---
|
334 |
-
|
335 |
-
**Happy containerized robotics! 🤖🐳**
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
README.md
CHANGED
@@ -37,17 +37,20 @@ The easiest way to run LeRobot Arena is using Docker, which sets up both the fro
|
|
37 |
### Step-by-Step Instructions
|
38 |
|
39 |
1. **Clone the repository**
|
|
|
40 |
```bash
|
41 |
git clone <your-repo-url>
|
42 |
cd lerobot-arena
|
43 |
```
|
44 |
|
45 |
2. **Build and start the services**
|
|
|
46 |
```bash
|
47 |
docker-compose up --build
|
48 |
```
|
49 |
|
50 |
3. **Access the application**
|
|
|
51 |
- **Frontend**: http://localhost:7860
|
52 |
- **Backend API**: http://localhost:8080
|
53 |
- **API Documentation**: http://localhost:8080/docs
|
@@ -158,17 +161,20 @@ For detailed Docker documentation, see [DOCKER_README.md](./DOCKER_README.md).
|
|
158 |
## 🔧 Building for Production
|
159 |
|
160 |
### Frontend Only
|
|
|
161 |
```bash
|
162 |
bun run build
|
163 |
```
|
164 |
|
165 |
### Backend Standalone Executable
|
|
|
166 |
```bash
|
167 |
cd src-python
|
168 |
box package
|
169 |
```
|
170 |
|
171 |
### Complete Docker Build
|
|
|
172 |
```bash
|
173 |
docker-compose up --build
|
174 |
```
|
@@ -185,6 +191,7 @@ docker-compose up --build
|
|
185 |
## 🚨 Troubleshooting
|
186 |
|
187 |
### Port Conflicts
|
|
|
188 |
If ports 8080 or 7860 are already in use:
|
189 |
|
190 |
```bash
|
@@ -197,6 +204,7 @@ docker run -p 8081:8080 -p 7861:7860 lerobot-arena
|
|
197 |
```
|
198 |
|
199 |
### Container Issues
|
|
|
200 |
```bash
|
201 |
# View logs
|
202 |
docker-compose logs lerobot-arena
|
@@ -207,6 +215,7 @@ docker-compose up
|
|
207 |
```
|
208 |
|
209 |
### Development Issues
|
|
|
210 |
```bash
|
211 |
# Clear node modules and reinstall
|
212 |
rm -rf node_modules
|
@@ -218,6 +227,7 @@ bun run dev
|
|
218 |
```
|
219 |
|
220 |
### Box-packager Issues
|
|
|
221 |
```bash
|
222 |
# Clean build artifacts
|
223 |
cd src-python
|
|
|
37 |
### Step-by-Step Instructions
|
38 |
|
39 |
1. **Clone the repository**
|
40 |
+
|
41 |
```bash
|
42 |
git clone <your-repo-url>
|
43 |
cd lerobot-arena
|
44 |
```
|
45 |
|
46 |
2. **Build and start the services**
|
47 |
+
|
48 |
```bash
|
49 |
docker-compose up --build
|
50 |
```
|
51 |
|
52 |
3. **Access the application**
|
53 |
+
|
54 |
- **Frontend**: http://localhost:7860
|
55 |
- **Backend API**: http://localhost:8080
|
56 |
- **API Documentation**: http://localhost:8080/docs
|
|
|
161 |
## 🔧 Building for Production
|
162 |
|
163 |
### Frontend Only
|
164 |
+
|
165 |
```bash
|
166 |
bun run build
|
167 |
```
|
168 |
|
169 |
### Backend Standalone Executable
|
170 |
+
|
171 |
```bash
|
172 |
cd src-python
|
173 |
box package
|
174 |
```
|
175 |
|
176 |
### Complete Docker Build
|
177 |
+
|
178 |
```bash
|
179 |
docker-compose up --build
|
180 |
```
|
|
|
191 |
## 🚨 Troubleshooting
|
192 |
|
193 |
### Port Conflicts
|
194 |
+
|
195 |
If ports 8080 or 7860 are already in use:
|
196 |
|
197 |
```bash
|
|
|
204 |
```
|
205 |
|
206 |
### Container Issues
|
207 |
+
|
208 |
```bash
|
209 |
# View logs
|
210 |
docker-compose logs lerobot-arena
|
|
|
215 |
```
|
216 |
|
217 |
### Development Issues
|
218 |
+
|
219 |
```bash
|
220 |
# Clear node modules and reinstall
|
221 |
rm -rf node_modules
|
|
|
227 |
```
|
228 |
|
229 |
### Box-packager Issues
|
230 |
+
|
231 |
```bash
|
232 |
# Clean build artifacts
|
233 |
cd src-python
|
ROBOT_ARCHITECTURE.md
CHANGED
@@ -1,416 +1,555 @@
|
|
1 |
-
# Robot Control Architecture v2.0
|
2 |
|
3 |
-
|
|
|
4 |
|
5 |
## 🏗️ Architecture Overview
|
6 |
|
7 |
-
The
|
8 |
|
9 |
```
|
10 |
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
11 |
-
│
|
12 |
│ │◄──►│ │◄──►│ │
|
|
|
13 |
│ • Manual Control│ │ • Master/Slave │ │ • Remote Server │
|
14 |
-
│ • Monitoring │ │
|
15 |
-
│ (disabled
|
16 |
│ master active) │ └──────────────────┘ └─────────────────┘
|
17 |
└─────────────────┘ │
|
18 |
▼
|
19 |
┌──────────────────┐ ┌─────────────────┐
|
20 |
-
│ Robot
|
21 |
│ │ │ │
|
22 |
-
│ •
|
23 |
-
│ •
|
24 |
-
│ • Command Queue │ │ •
|
|
|
25 |
└──────────────────┘ └─────────────────┘
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
26 |
```
|
27 |
|
28 |
-
|
29 |
-
|
30 |
-
### Masters (Command Sources)
|
31 |
-
- **Purpose**: Generate commands and control sequences
|
32 |
-
- **Examples**: Remote servers, predefined sequences, scripts
|
33 |
-
- **Limitation**: Only 1 master per robot (exclusive control)
|
34 |
-
- **Effect**: When active, disables manual panel control
|
35 |
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
|
|
|
|
|
|
|
|
|
|
41 |
|
42 |
-
|
43 |
-
1. **No Master**: Manual panel control → All slaves
|
44 |
-
2. **With Master**: Master commands → All slaves (panel disabled)
|
45 |
-
3. **Bidirectional**: Slaves provide real-time feedback
|
46 |
|
47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
48 |
|
|
|
49 |
```
|
50 |
-
src/
|
51 |
-
├──
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
│ ├── masters/
|
56 |
-
│ │ ├── MockSequenceMaster.ts # Demo sequences
|
57 |
-
│ │ ├── RemoteServerMaster.ts # HTTP/WebSocket commands
|
58 |
-
│ │ └── ScriptPlayerMaster.ts # Script execution
|
59 |
-
│ └── slaves/
|
60 |
-
│ ├── MockSlave.ts # Development/testing
|
61 |
-
│ ├── USBSlave.ts # Physical USB robots
|
62 |
-
│ └── SimulationSlave.ts # Physics simulation
|
63 |
-
├── types/
|
64 |
-
│ └── robotDriver.ts # Master/Slave interfaces
|
65 |
-
└── components/
|
66 |
-
├── scene/
|
67 |
-
│ └── Robot.svelte # 3D visualization
|
68 |
-
└── panel/
|
69 |
-
└── RobotControlPanel.svelte # Master/Slave connection UI
|
70 |
```
|
71 |
|
72 |
-
##
|
73 |
|
74 |
-
###
|
75 |
-
**Central orchestrator with master-slave management**
|
76 |
|
77 |
```typescript
|
78 |
-
import { robotManager } from
|
79 |
|
80 |
-
// Create robot
|
81 |
-
const robot = await robotManager.createRobot(
|
82 |
-
|
83 |
-
|
84 |
-
|
|
|
85 |
|
86 |
-
//
|
87 |
-
await robotManager.
|
|
|
88 |
|
89 |
-
// Connect
|
90 |
-
await robotManager.
|
91 |
|
92 |
-
//
|
93 |
-
await robotManager.disconnectMaster('my-robot');
|
94 |
```
|
95 |
|
96 |
-
###
|
97 |
-
**Individual robot with master-slave coordination**
|
98 |
|
99 |
```typescript
|
100 |
-
|
101 |
-
await robot.setMaster(sequenceMaster);
|
102 |
-
await robot.removeMaster();
|
103 |
-
|
104 |
-
// Slave management
|
105 |
-
await robot.addSlave(usbSlave);
|
106 |
-
await robot.addSlave(mockSlave);
|
107 |
-
await robot.removeSlave(slaveId);
|
108 |
-
|
109 |
-
// Control state
|
110 |
-
robot.controlState.hasActiveMaster // true when master connected
|
111 |
-
robot.controlState.manualControlEnabled // false when master active
|
112 |
-
robot.controlState.lastCommandSource // "master" | "manual" | "none"
|
113 |
-
|
114 |
-
// Manual control (only when no master)
|
115 |
-
await robot.updateJointValue('joint_1', 45);
|
116 |
-
```
|
117 |
|
118 |
-
|
|
|
119 |
|
120 |
-
|
121 |
-
|
|
|
122 |
|
123 |
-
|
124 |
-
|
125 |
-
|
126 |
-
|
127 |
-
|
128 |
-
|
129 |
-
};
|
130 |
-
|
131 |
-
await robotManager.connectMaster('robot-1', config);
|
132 |
-
```
|
133 |
|
134 |
-
|
135 |
-
-
|
136 |
-
|
137 |
-
|
138 |
|
139 |
-
|
140 |
-
**HTTP/WebSocket command reception**
|
141 |
|
142 |
-
|
143 |
-
const config: MasterDriverConfig = {
|
144 |
-
type: "remote-server",
|
145 |
-
url: "ws://robot-server:8080/ws",
|
146 |
-
apiKey: "your-api-key",
|
147 |
-
pollInterval: 100
|
148 |
-
};
|
149 |
-
```
|
150 |
|
151 |
-
|
152 |
-
**JavaScript/Python script execution**
|
153 |
|
154 |
```typescript
|
155 |
-
|
156 |
-
|
157 |
-
|
158 |
-
|
159 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
160 |
```
|
161 |
|
162 |
-
|
|
|
|
|
|
|
|
|
163 |
|
164 |
-
###
|
165 |
-
|
|
|
166 |
|
167 |
```typescript
|
168 |
-
|
169 |
-
|
170 |
-
|
171 |
-
|
172 |
-
|
173 |
-
|
174 |
-
|
175 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
176 |
```
|
177 |
|
178 |
**Features:**
|
179 |
-
-
|
180 |
-
-
|
181 |
-
-
|
182 |
-
-
|
183 |
|
184 |
-
###
|
185 |
-
|
|
|
186 |
|
187 |
```typescript
|
188 |
-
|
189 |
-
|
190 |
-
|
191 |
-
|
192 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
193 |
```
|
194 |
|
195 |
-
**
|
196 |
-
|
197 |
-
|
198 |
-
|
199 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
200 |
|
201 |
-
|
202 |
-
**Physics-based simulation**
|
203 |
|
204 |
```typescript
|
205 |
-
|
206 |
-
|
207 |
-
|
208 |
-
|
209 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
210 |
```
|
211 |
|
212 |
## 🔄 Command Flow Architecture
|
213 |
|
214 |
### Command Structure
|
|
|
215 |
```typescript
|
216 |
interface RobotCommand {
|
217 |
timestamp: number;
|
218 |
joints: {
|
219 |
name: string;
|
220 |
-
value: number;
|
221 |
-
speed?: number;
|
222 |
}[];
|
223 |
-
duration?: number;
|
224 |
metadata?: Record<string, unknown>;
|
225 |
}
|
226 |
```
|
227 |
|
228 |
-
###
|
229 |
-
|
230 |
-
#### Master Commands
|
231 |
-
```typescript
|
232 |
-
// Continuous sequence from master
|
233 |
-
master.onCommand((commands) => {
|
234 |
-
// Route to all connected slaves
|
235 |
-
robot.slaves.forEach(slave => slave.executeCommands(commands));
|
236 |
-
});
|
237 |
-
```
|
238 |
-
|
239 |
-
#### Manual Commands
|
240 |
-
```typescript
|
241 |
-
// Only when no master active
|
242 |
-
if (robot.manualControlEnabled) {
|
243 |
-
await robot.updateJointValue('shoulder', 45);
|
244 |
-
}
|
245 |
-
```
|
246 |
-
|
247 |
-
### Command Execution
|
248 |
-
```typescript
|
249 |
-
// All slaves execute in parallel
|
250 |
-
const executePromises = robot.connectedSlaves.map(slave =>
|
251 |
-
slave.executeCommand(command)
|
252 |
-
);
|
253 |
-
await Promise.allSettled(executePromises);
|
254 |
-
```
|
255 |
-
|
256 |
-
## 🎮 Usage Examples
|
257 |
-
|
258 |
-
### Basic Master-Slave Setup
|
259 |
-
```typescript
|
260 |
-
// 1. Create robot
|
261 |
-
const robot = await robotManager.createRobot('arm-1', urdfConfig);
|
262 |
-
|
263 |
-
// 2. Add slaves for execution
|
264 |
-
await robotManager.connectMockSlave('arm-1'); // Development
|
265 |
-
await robotManager.connectUSBSlave('arm-1'); // Real hardware
|
266 |
-
|
267 |
-
// 3. Connect master for control
|
268 |
-
await robotManager.connectDemoSequences('arm-1'); // Automated sequences
|
269 |
-
|
270 |
-
// 4. Monitor status
|
271 |
-
const status = robotManager.getRobotStatus('arm-1');
|
272 |
-
console.log(`Master: ${status.masterName}, Slaves: ${status.connectedSlaves}`);
|
273 |
-
```
|
274 |
-
|
275 |
-
### Multiple Robots with Different Masters
|
276 |
-
```typescript
|
277 |
-
// Robot 1: Demo sequences
|
278 |
-
await robotManager.createRobot('demo-1', armConfig);
|
279 |
-
await robotManager.connectDemoSequences('demo-1', true);
|
280 |
-
await robotManager.connectMockSlave('demo-1');
|
281 |
-
|
282 |
-
// Robot 2: Remote control
|
283 |
-
await robotManager.createRobot('remote-1', armConfig);
|
284 |
-
await robotManager.connectMaster('remote-1', remoteServerConfig);
|
285 |
-
await robotManager.connectUSBSlave('remote-1');
|
286 |
-
|
287 |
-
// Robot 3: Manual control only
|
288 |
-
await robotManager.createRobot('manual-1', armConfig);
|
289 |
-
await robotManager.connectMockSlave('manual-1');
|
290 |
-
// No master = manual control enabled
|
291 |
-
```
|
292 |
|
293 |
-
|
294 |
-
|
295 |
-
|
296 |
-
|
297 |
-
|
298 |
|
299 |
-
|
300 |
-
await robotManager.disconnectMaster('robot-1');
|
301 |
-
// Panel inputs re-enabled, manual control restored
|
302 |
-
```
|
303 |
|
304 |
-
## 🚀 Benefits
|
305 |
-
|
306 |
-
### 1. **Clear Control Hierarchy**
|
307 |
-
- Masters provide commands exclusively
|
308 |
-
- Slaves execute commands in parallel
|
309 |
-
- No ambiguity about command source
|
310 |
-
|
311 |
-
### 2. **Flexible Command Sources**
|
312 |
-
- Remote servers for network control
|
313 |
-
- Predefined sequences for automation
|
314 |
-
- Manual control for testing/setup
|
315 |
-
- Easy to add new master types
|
316 |
-
|
317 |
-
### 3. **Multiple Execution Targets**
|
318 |
-
- Physical robots via USB
|
319 |
-
- Simulated robots for testing
|
320 |
-
- Mock robots for development
|
321 |
-
- All execute same commands simultaneously
|
322 |
-
|
323 |
-
### 4. **Automatic Panel Management**
|
324 |
-
- Panel disabled when master active
|
325 |
-
- Panel re-enabled when master disconnected
|
326 |
-
- Clear visual feedback about control state
|
327 |
-
|
328 |
-
### 5. **Safe Operation**
|
329 |
-
- Masters cannot conflict (only 1 allowed)
|
330 |
-
- Graceful disconnection with rest positioning
|
331 |
-
- Error isolation between slaves
|
332 |
-
|
333 |
-
### 6. **Development Workflow**
|
334 |
-
- Test with mock slaves during development
|
335 |
-
- Add real slaves for deployment
|
336 |
-
- Switch masters for different scenarios
|
337 |
-
|
338 |
-
## 🔮 Implementation Roadmap
|
339 |
-
|
340 |
-
### Phase 1: Core Architecture ✅
|
341 |
-
- [x] Master-Slave interfaces
|
342 |
-
- [x] Robot class with dual management
|
343 |
-
- [x] RobotManager orchestration
|
344 |
-
- [x] MockSequenceMaster with demo patterns
|
345 |
-
- [x] MockSlave for testing
|
346 |
-
|
347 |
-
### Phase 2: Real Hardware
|
348 |
-
- [ ] USBSlave implementation (feetech.js integration)
|
349 |
-
- [ ] Calibration system for hardware slaves
|
350 |
-
- [ ] Error handling and recovery
|
351 |
-
|
352 |
-
### Phase 3: Remote Control
|
353 |
-
- [ ] RemoteServerMaster (HTTP/WebSocket)
|
354 |
-
- [ ] Authentication and security
|
355 |
-
- [ ] Real-time command streaming
|
356 |
-
|
357 |
-
### Phase 4: Advanced Features
|
358 |
-
- [ ] ScriptPlayerMaster (JS/Python execution)
|
359 |
-
- [ ] SimulationSlave (physics integration)
|
360 |
-
- [ ] Command recording and playback
|
361 |
-
- [ ] Multi-robot coordination
|
362 |
-
|
363 |
-
## 🛡️ Safety Features
|
364 |
-
|
365 |
-
### Master Safety
|
366 |
-
- Only 1 master per robot prevents conflicts
|
367 |
-
- Master disconnect automatically restores manual control
|
368 |
-
- Command validation before execution
|
369 |
-
|
370 |
-
### Slave Safety
|
371 |
-
- Rest positioning before disconnect
|
372 |
-
- Smooth movement transitions
|
373 |
-
- Individual slave error isolation
|
374 |
-
- Calibration offset compensation
|
375 |
-
|
376 |
-
### System Safety
|
377 |
-
- Graceful degradation on slave failures
|
378 |
-
- Command queuing prevents overwhelming
|
379 |
-
- Real-time state monitoring
|
380 |
-
- Emergency stop capabilities *(planned)*
|
381 |
-
|
382 |
-
## 🔧 Migration from v1.0
|
383 |
-
|
384 |
-
The new architecture is **completely different** from the old driver pattern:
|
385 |
-
|
386 |
-
### Old Architecture (v1.0)
|
387 |
```typescript
|
388 |
-
//
|
389 |
-
|
390 |
-
|
391 |
-
|
392 |
-
|
393 |
-
|
394 |
-
|
395 |
-
|
396 |
-
//
|
397 |
-
|
398 |
-
|
399 |
-
|
400 |
-
|
401 |
-
|
402 |
-
|
|
|
|
|
|
|
|
|
403 |
}
|
404 |
```
|
405 |
|
406 |
-
|
407 |
-
1. **Drivers → Masters + Slaves**: Split command/execution concerns
|
408 |
-
2. **Single Connection → Multiple**: 1 master + N slaves per robot
|
409 |
-
3. **Always Manual → Conditional**: Panel disabled when master active
|
410 |
-
4. **Direct Control → Command Routing**: Masters route to all slaves
|
411 |
|
412 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
413 |
|
414 |
---
|
415 |
|
416 |
-
|
|
|
1 |
+
# LeRobot Arena - Robot Control Architecture v2.0
|
2 |
|
3 |
+
> **Master-Slave Pattern for Scalable Robot Control**
|
4 |
+
> A revolutionary architecture that separates command generation (Masters) from execution (Slaves), enabling sophisticated robot control scenarios from simple manual operation to complex multi-robot coordination.
|
5 |
|
6 |
## 🏗️ Architecture Overview
|
7 |
|
8 |
+
The architecture follows a **Master-Slave Pattern** with complete separation of concerns:
|
9 |
|
10 |
```
|
11 |
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
12 |
+
│ Web Frontend │ │ RobotManager │ │ Masters │
|
13 |
│ │◄──►│ │◄──►│ │
|
14 |
+
│ • 3D Visualization │ • Robot Creation │ │ • USB Master │
|
15 |
│ • Manual Control│ │ • Master/Slave │ │ • Remote Server │
|
16 |
+
│ • Monitoring │ │ Orchestration │ │ • Mock Sequence │
|
17 |
+
│ (disabled when │ │ • State Sync │ │ (1 per robot) │
|
18 |
│ master active) │ └──────────────────┘ └─────────────────┘
|
19 |
└─────────────────┘ │
|
20 |
▼
|
21 |
┌──────────────────┐ ┌─────────────────┐
|
22 |
+
│ Robot Class │◄──►│ Slaves │
|
23 |
│ │ │ │
|
24 |
+
│ • Joint States │ │ • USB Robot │
|
25 |
+
│ • URDF Model │ │ • Remote Robot │
|
26 |
+
│ • Command Queue │ │ • WebSocket │
|
27 |
+
│ • Calibration │ │ (N per robot) │
|
28 |
└──────────────────┘ └─────────────────┘
|
29 |
+
│
|
30 |
+
▼
|
31 |
+
┌──────────────────┐
|
32 |
+
│ Python Backend │
|
33 |
+
│ │
|
34 |
+
│ • WebSocket API │
|
35 |
+
│ • Connection Mgr │
|
36 |
+
│ • Robot Manager │
|
37 |
+
└──────────────────┘
|
38 |
```
|
39 |
|
40 |
+
### Control Flow States
|
|
|
|
|
|
|
|
|
|
|
|
|
41 |
|
42 |
+
```
|
43 |
+
┌─────────────────┐ Master Connected ┌─────────────────┐
|
44 |
+
│ Manual Mode │ ──────────────────────► │ Master Mode │
|
45 |
+
│ │ │ │
|
46 |
+
│ ✅ Panel Active │ │ ❌ Panel Locked │
|
47 |
+
│ ✅ Direct Control│ │ ✅ Master Commands│
|
48 |
+
│ ❌ No Master │ │ ✅ All Slaves Exec│
|
49 |
+
└─────────────────┘ ◄────────────────────── └─────────────────┘
|
50 |
+
Master Disconnected
|
51 |
+
```
|
52 |
|
53 |
+
## 🎯 Core Concepts
|
|
|
|
|
|
|
54 |
|
55 |
+
### Masters (Command Sources)
|
56 |
+
**Purpose**: Generate and provide robot control commands
|
57 |
+
|
58 |
+
| Type | Description | Connection | Use Case |
|
59 |
+
|------|-------------|------------|----------|
|
60 |
+
| **USB Master** | Physical robot as command source | USB/Serial | Teleoperation, motion teaching |
|
61 |
+
| **Remote Server** | WebSocket/HTTP command reception | Network | External control systems |
|
62 |
+
| **Mock Sequence** | Predefined movement patterns | Internal | Testing, demonstrations |
|
63 |
+
|
64 |
+
**Key Rules**:
|
65 |
+
- 🔒 **Exclusive Control**: Only 1 master per robot
|
66 |
+
- 🚫 **Panel Lock**: Manual control disabled when master active
|
67 |
+
- 🔄 **Seamless Switch**: Masters can be swapped dynamically
|
68 |
+
|
69 |
+
### Slaves (Execution Targets)
|
70 |
+
**Purpose**: Execute commands on physical or virtual robots
|
71 |
+
|
72 |
+
| Type | Description | Connection | Use Case |
|
73 |
+
|------|-------------|------------|----------|
|
74 |
+
| **USB Slave** | Physical robot control | USB/Serial | Hardware execution |
|
75 |
+
| **Remote Server Slave** | Network robot control | WebSocket | Distributed robots |
|
76 |
+
| **WebSocket Slave** | Real-time WebSocket execution | WebSocket | Cloud robots |
|
77 |
+
|
78 |
+
**Key Rules**:
|
79 |
+
- 🔢 **Multiple Allowed**: N slaves per robot
|
80 |
+
- 🎯 **Parallel Execution**: All slaves execute same commands
|
81 |
+
- 🔄 **Independent Operation**: Slaves can fail independently
|
82 |
+
|
83 |
+
### Architecture Comparison
|
84 |
+
|
85 |
+
| Aspect | v1.0 (Single Driver) | v2.0 (Master-Slave) |
|
86 |
+
|--------|---------------------|---------------------|
|
87 |
+
| **Connection Model** | 1 Driver ↔ 1 Robot | 1 Master + N Slaves ↔ 1 Robot |
|
88 |
+
| **Command Source** | Always UI Panel | Master OR UI Panel |
|
89 |
+
| **Execution Targets** | Single Connection | Multiple Parallel |
|
90 |
+
| **Control Hierarchy** | Flat | Hierarchical |
|
91 |
+
| **Scalability** | Limited | Unlimited |
|
92 |
+
|
93 |
+
## 📁 Project Structure
|
94 |
+
|
95 |
+
### Frontend Architecture (TypeScript + Svelte)
|
96 |
+
```
|
97 |
+
src/lib/robot/
|
98 |
+
├── Robot.svelte.ts # Individual robot master-slave coordination
|
99 |
+
├── RobotManager.svelte.ts # Global robot orchestration
|
100 |
+
└── drivers/
|
101 |
+
├── USBMaster.ts # Physical robot as command source
|
102 |
+
├── RemoteServerMaster.ts # WebSocket command reception
|
103 |
+
├── USBSlave.ts # Physical robot execution
|
104 |
+
├── RemoteServerSlave.ts # Network robot execution
|
105 |
+
└── WebSocketSlave.ts # Real-time WebSocket execution
|
106 |
+
|
107 |
+
src/lib/types/
|
108 |
+
├── robotDriver.ts # Master/Slave interfaces
|
109 |
+
└── robot.ts # Robot state management
|
110 |
+
```
|
111 |
|
112 |
+
### Backend Architecture (Python + FastAPI)
|
113 |
```
|
114 |
+
src-python/src/
|
115 |
+
├── main.py # FastAPI server + WebSocket endpoints
|
116 |
+
├── robot_manager.py # Server-side robot lifecycle
|
117 |
+
├── connection_manager.py # WebSocket connection handling
|
118 |
+
└── models.py # Pydantic data models
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
119 |
```
|
120 |
|
121 |
+
## 🎮 Usage Examples
|
122 |
|
123 |
+
### Basic Robot Setup
|
|
|
124 |
|
125 |
```typescript
|
126 |
+
import { robotManager } from "$lib/robot/RobotManager.svelte";
|
127 |
|
128 |
+
// Create robot from URDF
|
129 |
+
const robot = await robotManager.createRobot("demo-arm", {
|
130 |
+
urdfPath: "/robots/so-arm100/robot.urdf",
|
131 |
+
jointNameIdMap: { "Rotation": 1, "Pitch": 2, "Elbow": 3 },
|
132 |
+
restPosition: { "Rotation": 0, "Pitch": 0, "Elbow": 0 }
|
133 |
+
});
|
134 |
|
135 |
+
// Add execution targets (slaves)
|
136 |
+
await robotManager.connectUSBSlave("demo-arm"); // Real hardware
|
137 |
+
await robotManager.connectRemoteServerSlave("demo-arm"); // Network robot
|
138 |
|
139 |
+
// Connect command source (master) - panel becomes locked
|
140 |
+
await robotManager.connectUSBMaster("demo-arm");
|
141 |
|
142 |
+
// Result: USB master controls both USB and Remote slaves
|
|
|
143 |
```
|
144 |
|
145 |
+
### Master Switching Workflow
|
|
|
146 |
|
147 |
```typescript
|
148 |
+
const robot = robotManager.getRobot("my-robot");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
149 |
|
150 |
+
// Start with manual control
|
151 |
+
console.log(robot.manualControlEnabled); // ✅ true
|
152 |
|
153 |
+
// Switch to USB master (robot becomes command source)
|
154 |
+
await robotManager.connectUSBMaster("my-robot");
|
155 |
+
console.log(robot.manualControlEnabled); // ❌ false (panel locked)
|
156 |
|
157 |
+
// Switch to remote control
|
158 |
+
await robotManager.disconnectMaster("my-robot");
|
159 |
+
await robotManager.connectMaster("my-robot", {
|
160 |
+
type: "remote-server",
|
161 |
+
url: "ws://robot-controller:8080/ws"
|
162 |
+
});
|
|
|
|
|
|
|
|
|
163 |
|
164 |
+
// Restore manual control
|
165 |
+
await robotManager.disconnectMaster("my-robot");
|
166 |
+
console.log(robot.manualControlEnabled); // ✅ true (panel restored)
|
167 |
+
```
|
168 |
|
169 |
+
## 🔌 Driver Implementations
|
|
|
170 |
|
171 |
+
### USB Master Driver
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
172 |
|
173 |
+
**Physical robot as command source for teleoperation**
|
|
|
174 |
|
175 |
```typescript
|
176 |
+
// USBMaster.ts - Core implementation
|
177 |
+
export class USBMaster implements MasterDriver {
|
178 |
+
readonly type = "master" as const;
|
179 |
+
private feetechDriver: FeetechSerialDriver;
|
180 |
+
private pollIntervalId?: number;
|
181 |
+
|
182 |
+
async connect(): Promise<void> {
|
183 |
+
// Initialize feetech.js serial connection
|
184 |
+
this.feetechDriver = new FeetechSerialDriver({
|
185 |
+
port: this.config.port || await this.detectPort(),
|
186 |
+
baudRate: this.config.baudRate || 115200
|
187 |
+
});
|
188 |
+
|
189 |
+
await this.feetechDriver.connect();
|
190 |
+
this.startPolling();
|
191 |
+
}
|
192 |
+
|
193 |
+
private startPolling(): void {
|
194 |
+
this.pollIntervalId = setInterval(async () => {
|
195 |
+
try {
|
196 |
+
// Read current joint positions from hardware
|
197 |
+
const jointStates = await this.readAllJoints();
|
198 |
+
|
199 |
+
// Convert to robot commands
|
200 |
+
const commands = this.convertToCommands(jointStates);
|
201 |
+
|
202 |
+
// Emit commands to slaves
|
203 |
+
this.notifyCommand(commands);
|
204 |
+
} catch (error) {
|
205 |
+
console.error('USB Master polling error:', error);
|
206 |
+
}
|
207 |
+
}, this.config.pollInterval || 100);
|
208 |
+
}
|
209 |
+
|
210 |
+
private async readAllJoints(): Promise<DriverJointState[]> {
|
211 |
+
const states: DriverJointState[] = [];
|
212 |
+
|
213 |
+
for (const [jointName, servoId] of Object.entries(this.jointMap)) {
|
214 |
+
const position = await this.feetechDriver.readPosition(servoId);
|
215 |
+
states.push({
|
216 |
+
name: jointName,
|
217 |
+
servoId,
|
218 |
+
type: "revolute",
|
219 |
+
virtualValue: position,
|
220 |
+
realValue: position
|
221 |
+
});
|
222 |
+
}
|
223 |
+
|
224 |
+
return states;
|
225 |
+
}
|
226 |
+
}
|
227 |
```
|
228 |
|
229 |
+
**Usage Pattern:**
|
230 |
+
- Connect USB robot as master
|
231 |
+
- Physical robot becomes the command source
|
232 |
+
- Move robot manually → slaves follow the movement
|
233 |
+
- Ideal for: Teleoperation, motion teaching, demonstration recording
|
234 |
|
235 |
+
### USB Slave Driver
|
236 |
+
|
237 |
+
**Physical robot as execution target**
|
238 |
|
239 |
```typescript
|
240 |
+
// USBSlave.ts - Core implementation
|
241 |
+
export class USBSlave implements SlaveDriver {
|
242 |
+
readonly type = "slave" as const;
|
243 |
+
private feetechDriver: FeetechSerialDriver;
|
244 |
+
private calibrationOffsets: Map<string, number> = new Map();
|
245 |
+
|
246 |
+
async executeCommand(command: RobotCommand): Promise<void> {
|
247 |
+
for (const joint of command.joints) {
|
248 |
+
const servoId = this.getServoId(joint.name);
|
249 |
+
if (!servoId) continue;
|
250 |
+
|
251 |
+
// Apply calibration offset
|
252 |
+
const offset = this.calibrationOffsets.get(joint.name) || 0;
|
253 |
+
const adjustedValue = joint.value + offset;
|
254 |
+
|
255 |
+
// Send to hardware via feetech.js
|
256 |
+
await this.feetechDriver.writePosition(servoId, adjustedValue, {
|
257 |
+
speed: joint.speed || 100,
|
258 |
+
acceleration: 50
|
259 |
+
});
|
260 |
+
}
|
261 |
+
}
|
262 |
+
|
263 |
+
async readJointStates(): Promise<DriverJointState[]> {
|
264 |
+
const states: DriverJointState[] = [];
|
265 |
+
|
266 |
+
for (const joint of this.jointStates) {
|
267 |
+
const position = await this.feetechDriver.readPosition(joint.servoId);
|
268 |
+
const offset = this.calibrationOffsets.get(joint.name) || 0;
|
269 |
+
|
270 |
+
states.push({
|
271 |
+
...joint,
|
272 |
+
realValue: position - offset // Remove offset for accurate state
|
273 |
+
});
|
274 |
+
}
|
275 |
+
|
276 |
+
return states;
|
277 |
+
}
|
278 |
+
|
279 |
+
async calibrate(): Promise<void> {
|
280 |
+
console.log('Calibrating USB robot...');
|
281 |
+
|
282 |
+
for (const joint of this.jointStates) {
|
283 |
+
// Read current hardware position
|
284 |
+
const currentPos = await this.feetechDriver.readPosition(joint.servoId);
|
285 |
+
|
286 |
+
// Calculate offset: desired_rest - actual_position
|
287 |
+
const offset = joint.restPosition - currentPos;
|
288 |
+
this.calibrationOffsets.set(joint.name, offset);
|
289 |
+
|
290 |
+
console.log(`Joint ${joint.name}: offset=${offset.toFixed(1)}°`);
|
291 |
+
}
|
292 |
+
}
|
293 |
+
}
|
294 |
```
|
295 |
|
296 |
**Features:**
|
297 |
+
- Direct hardware control via feetech.js
|
298 |
+
- Real position feedback
|
299 |
+
- Calibration offset support
|
300 |
+
- Smooth motion interpolation
|
301 |
|
302 |
+
### Remote Server Master
|
303 |
+
|
304 |
+
**Network command reception via WebSocket**
|
305 |
|
306 |
```typescript
|
307 |
+
// RemoteServerMaster.ts - Core implementation
|
308 |
+
export class RemoteServerMaster implements MasterDriver {
|
309 |
+
readonly type = "master" as const;
|
310 |
+
private websocket?: WebSocket;
|
311 |
+
private reconnectAttempts = 0;
|
312 |
+
|
313 |
+
async connect(): Promise<void> {
|
314 |
+
const wsUrl = `${this.config.url}/ws/master/${this.robotId}`;
|
315 |
+
this.websocket = new WebSocket(wsUrl);
|
316 |
+
|
317 |
+
this.websocket.onopen = () => {
|
318 |
+
console.log(`Remote master connected: ${wsUrl}`);
|
319 |
+
this.reconnectAttempts = 0;
|
320 |
+
this.updateStatus({ isConnected: true });
|
321 |
+
};
|
322 |
+
|
323 |
+
this.websocket.onmessage = (event) => {
|
324 |
+
try {
|
325 |
+
const message = JSON.parse(event.data);
|
326 |
+
this.handleServerMessage(message);
|
327 |
+
} catch (error) {
|
328 |
+
console.error('Failed to parse server message:', error);
|
329 |
+
}
|
330 |
+
};
|
331 |
+
|
332 |
+
this.websocket.onclose = () => {
|
333 |
+
this.updateStatus({ isConnected: false });
|
334 |
+
this.attemptReconnect();
|
335 |
+
};
|
336 |
+
}
|
337 |
+
|
338 |
+
private handleServerMessage(message: any): void {
|
339 |
+
switch (message.type) {
|
340 |
+
case 'command':
|
341 |
+
// Convert server message to robot command
|
342 |
+
const command: RobotCommand = {
|
343 |
+
timestamp: Date.now(),
|
344 |
+
joints: message.data.joints.map((j: any) => ({
|
345 |
+
name: j.name,
|
346 |
+
value: j.value,
|
347 |
+
speed: j.speed
|
348 |
+
}))
|
349 |
+
};
|
350 |
+
this.notifyCommand([command]);
|
351 |
+
break;
|
352 |
+
|
353 |
+
case 'sequence':
|
354 |
+
// Handle command sequence
|
355 |
+
const sequence: CommandSequence = message.data;
|
356 |
+
this.notifySequence(sequence);
|
357 |
+
break;
|
358 |
+
}
|
359 |
+
}
|
360 |
+
|
361 |
+
async sendSlaveStatus(slaveStates: DriverJointState[]): Promise<void> {
|
362 |
+
if (!this.websocket) return;
|
363 |
+
|
364 |
+
const statusMessage = {
|
365 |
+
type: 'slave_status',
|
366 |
+
timestamp: new Date().toISOString(),
|
367 |
+
robot_id: this.robotId,
|
368 |
+
data: {
|
369 |
+
joints: slaveStates.map(state => ({
|
370 |
+
name: state.name,
|
371 |
+
virtual_value: state.virtualValue,
|
372 |
+
real_value: state.realValue
|
373 |
+
}))
|
374 |
+
}
|
375 |
+
};
|
376 |
+
|
377 |
+
this.websocket.send(JSON.stringify(statusMessage));
|
378 |
+
}
|
379 |
+
}
|
380 |
```
|
381 |
|
382 |
+
**Protocol:**
|
383 |
+
```json
|
384 |
+
// Command from server to robot
|
385 |
+
{
|
386 |
+
"type": "command",
|
387 |
+
"timestamp": "2024-01-15T10:30:00Z",
|
388 |
+
"data": {
|
389 |
+
"joints": [
|
390 |
+
{ "name": "Rotation", "value": 45, "speed": 100 },
|
391 |
+
{ "name": "Elbow", "value": -30, "speed": 80 }
|
392 |
+
]
|
393 |
+
}
|
394 |
+
}
|
395 |
+
|
396 |
+
// Status from robot to server
|
397 |
+
{
|
398 |
+
"type": "slave_status",
|
399 |
+
"timestamp": "2024-01-15T10:30:01Z",
|
400 |
+
"robot_id": "robot-1",
|
401 |
+
"data": {
|
402 |
+
"joints": [
|
403 |
+
{ "name": "Rotation", "virtual_value": 45, "real_value": 44.8 },
|
404 |
+
{ "name": "Elbow", "virtual_value": -30, "real_value": -29.9 }
|
405 |
+
]
|
406 |
+
}
|
407 |
+
}
|
408 |
+
```
|
409 |
+
|
410 |
+
### Remote Server Slave
|
411 |
|
412 |
+
**Network robot execution via WebSocket**
|
|
|
413 |
|
414 |
```typescript
|
415 |
+
// RemoteServerSlave.ts - Core implementation
|
416 |
+
export class RemoteServerSlave implements SlaveDriver {
|
417 |
+
readonly type = "slave" as const;
|
418 |
+
private websocket?: WebSocket;
|
419 |
+
|
420 |
+
async executeCommand(command: RobotCommand): Promise<void> {
|
421 |
+
if (!this.websocket) throw new Error('Not connected');
|
422 |
+
|
423 |
+
const message = {
|
424 |
+
type: 'command',
|
425 |
+
timestamp: new Date().toISOString(),
|
426 |
+
robot_id: this.config.robotId,
|
427 |
+
data: {
|
428 |
+
joints: command.joints.map(j => ({
|
429 |
+
name: j.name,
|
430 |
+
value: j.value,
|
431 |
+
speed: j.speed
|
432 |
+
}))
|
433 |
+
}
|
434 |
+
};
|
435 |
+
|
436 |
+
this.websocket.send(JSON.stringify(message));
|
437 |
+
|
438 |
+
// Wait for acknowledgment
|
439 |
+
return new Promise((resolve, reject) => {
|
440 |
+
const timeout = setTimeout(() => reject(new Error('Command timeout')), 5000);
|
441 |
+
|
442 |
+
const messageHandler = (event: MessageEvent) => {
|
443 |
+
const response = JSON.parse(event.data);
|
444 |
+
if (response.type === 'command_ack') {
|
445 |
+
clearTimeout(timeout);
|
446 |
+
this.websocket?.removeEventListener('message', messageHandler);
|
447 |
+
resolve();
|
448 |
+
}
|
449 |
+
};
|
450 |
+
|
451 |
+
this.websocket.addEventListener('message', messageHandler);
|
452 |
+
});
|
453 |
+
}
|
454 |
+
|
455 |
+
async readJointStates(): Promise<DriverJointState[]> {
|
456 |
+
if (!this.websocket) throw new Error('Not connected');
|
457 |
+
|
458 |
+
const message = {
|
459 |
+
type: 'status_request',
|
460 |
+
timestamp: new Date().toISOString(),
|
461 |
+
robot_id: this.config.robotId
|
462 |
+
};
|
463 |
+
|
464 |
+
this.websocket.send(JSON.stringify(message));
|
465 |
+
|
466 |
+
return new Promise((resolve, reject) => {
|
467 |
+
const timeout = setTimeout(() => reject(new Error('Status timeout')), 3000);
|
468 |
+
|
469 |
+
const messageHandler = (event: MessageEvent) => {
|
470 |
+
const response = JSON.parse(event.data);
|
471 |
+
if (response.type === 'joint_states') {
|
472 |
+
clearTimeout(timeout);
|
473 |
+
this.websocket?.removeEventListener('message', messageHandler);
|
474 |
+
|
475 |
+
const states = response.data.joints.map((j: any) => ({
|
476 |
+
name: j.name,
|
477 |
+
servoId: j.servo_id,
|
478 |
+
type: j.type,
|
479 |
+
virtualValue: j.virtual_value,
|
480 |
+
realValue: j.real_value
|
481 |
+
}));
|
482 |
+
|
483 |
+
resolve(states);
|
484 |
+
}
|
485 |
+
};
|
486 |
+
|
487 |
+
this.websocket.addEventListener('message', messageHandler);
|
488 |
+
});
|
489 |
+
}
|
490 |
+
}
|
491 |
```
|
492 |
|
493 |
## 🔄 Command Flow Architecture
|
494 |
|
495 |
### Command Structure
|
496 |
+
|
497 |
```typescript
|
498 |
interface RobotCommand {
|
499 |
timestamp: number;
|
500 |
joints: {
|
501 |
name: string;
|
502 |
+
value: number; // degrees for revolute, speed for continuous
|
503 |
+
speed?: number; // optional movement speed
|
504 |
}[];
|
505 |
+
duration?: number; // optional execution time
|
506 |
metadata?: Record<string, unknown>;
|
507 |
}
|
508 |
```
|
509 |
|
510 |
+
### Control Flow
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
511 |
|
512 |
+
1. **Master Generation**: Masters generate commands from various sources
|
513 |
+
2. **Robot Routing**: Robot class routes commands to all connected slaves
|
514 |
+
3. **Parallel Execution**: All slaves execute commands simultaneously
|
515 |
+
4. **State Feedback**: Slaves report back real joint positions
|
516 |
+
5. **Synchronization**: Robot maintains synchronized state across all slaves
|
517 |
|
518 |
+
### State Management
|
|
|
|
|
|
|
519 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
520 |
```typescript
|
521 |
+
// Robot.svelte.ts - Core state management
|
522 |
+
export interface ManagedJointState {
|
523 |
+
name: string;
|
524 |
+
urdfJoint: IUrdfJoint;
|
525 |
+
servoId?: number;
|
526 |
+
|
527 |
+
// State values
|
528 |
+
virtualValue: number; // What the UI shows
|
529 |
+
realValue?: number; // What hardware reports
|
530 |
+
commandedValue: number; // Last commanded value
|
531 |
+
|
532 |
+
// Calibration
|
533 |
+
calibrationOffset: number; // Hardware compensation
|
534 |
+
restPosition: number; // Safe default position
|
535 |
+
|
536 |
+
// Synchronization
|
537 |
+
lastVirtualUpdate: Date;
|
538 |
+
lastRealUpdate?: Date;
|
539 |
+
lastCommandUpdate?: Date;
|
540 |
}
|
541 |
```
|
542 |
|
543 |
+
## 📊 Benefits Summary
|
|
|
|
|
|
|
|
|
544 |
|
545 |
+
| Benefit | Description | Impact |
|
546 |
+
|---------|-------------|---------|
|
547 |
+
| **🔒 Clear Control Hierarchy** | Masters provide commands exclusively, slaves execute in parallel | No command conflicts, predictable behavior |
|
548 |
+
| **🔄 Flexible Command Sources** | Easy switching between manual, automated, and remote control | Supports development, testing, and production |
|
549 |
+
| **📡 Multiple Execution Targets** | Same commands executed on multiple robots simultaneously | Real hardware + simulation testing |
|
550 |
+
| **🎛️ Automatic Panel Management** | UI automatically adapts to master presence | Intuitive user experience |
|
551 |
+
| **🚀 Development Workflow** | Clear separation enables independent development | Faster iteration cycles |
|
552 |
|
553 |
---
|
554 |
|
555 |
+
**This architecture provides unprecedented flexibility for robot control, from simple manual operation to sophisticated multi-robot coordination, all with a clean, extensible, and production-ready design.**
|
ROBOT_INSTANCING_README.md
DELETED
@@ -1,73 +0,0 @@
|
|
1 |
-
# Robot Instancing Optimization
|
2 |
-
|
3 |
-
This implementation optimizes the rendering of multiple robots using Threlte's instancing capabilities for improved performance.
|
4 |
-
|
5 |
-
## Key Features
|
6 |
-
|
7 |
-
### 1. **Smart Instancing by Robot Type**
|
8 |
-
- Robots are grouped by their URDF type (URL) for optimal instancing
|
9 |
-
- Single robots render with full detail
|
10 |
-
- Multiple robots of the same type use instanced rendering
|
11 |
-
|
12 |
-
### 2. **Geometry-Specific Instancing**
|
13 |
-
- **Box Geometries**: Instanced with `T.boxGeometry`
|
14 |
-
- **Cylinder Geometries**: Instanced with `T.cylinderGeometry`
|
15 |
-
- **Mesh Geometries**: Instanced with simplified `T.sphereGeometry` for performance
|
16 |
-
|
17 |
-
### 3. **Hybrid Rendering Strategy**
|
18 |
-
- First robot of each type: Full detailed rendering with all URDF components
|
19 |
-
- Additional robots: Simplified instanced representation
|
20 |
-
- Maintains visual quality while optimizing performance
|
21 |
-
|
22 |
-
### 4. **Performance Benefits**
|
23 |
-
- Reduces draw calls when rendering multiple robots
|
24 |
-
- Optimizes GPU memory usage through instancing
|
25 |
-
- Scales better with increasing robot count
|
26 |
-
- Maintains interactivity for detailed robots
|
27 |
-
|
28 |
-
## Implementation Details
|
29 |
-
|
30 |
-
### State Management
|
31 |
-
```typescript
|
32 |
-
// Robots grouped by URDF type for optimal batching
|
33 |
-
let robotsByType: Record<string, Array<{
|
34 |
-
id: string;
|
35 |
-
position: [number, number, number];
|
36 |
-
robotState: RobotState
|
37 |
-
}>> = $state({});
|
38 |
-
```
|
39 |
-
|
40 |
-
### Instancing Logic
|
41 |
-
1. **Single Robot**: Full `UrdfLink` rendering with all details
|
42 |
-
2. **Multiple Robots**:
|
43 |
-
- Geometry analysis and grouping
|
44 |
-
- Instanced rendering for primitive shapes
|
45 |
-
- Simplified representations for complex meshes
|
46 |
-
|
47 |
-
### Automatic Demonstration
|
48 |
-
- Spawns additional robots after 2 seconds to showcase instancing
|
49 |
-
- Shows performance difference between single and multiple robot rendering
|
50 |
-
|
51 |
-
## Usage
|
52 |
-
|
53 |
-
Simply use the `Robot.svelte` component with a `urdfConfig`. The component automatically:
|
54 |
-
- Detects when multiple robots of the same type exist
|
55 |
-
- Switches to instanced rendering for optimal performance
|
56 |
-
- Maintains full detail for single robots
|
57 |
-
|
58 |
-
```svelte
|
59 |
-
<Robot {urdfConfig} />
|
60 |
-
```
|
61 |
-
|
62 |
-
## Performance Impact
|
63 |
-
|
64 |
-
- **Before**: O(n) draw calls for n robots
|
65 |
-
- **After**: O(1) draw calls per geometry type regardless of robot count
|
66 |
-
- **Memory**: Shared geometry instances reduce GPU memory usage
|
67 |
-
- **Scalability**: Linear performance improvement with robot count
|
68 |
-
|
69 |
-
This optimization is particularly beneficial for:
|
70 |
-
- Robot swarms
|
71 |
-
- Multi-robot simulations
|
72 |
-
- Arena scenarios with many similar robots
|
73 |
-
- Performance-critical real-time applications
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
bun.lock
CHANGED
@@ -6,8 +6,9 @@
|
|
6 |
"dependencies": {
|
7 |
"@threlte/core": "^8.0.4",
|
8 |
"@threlte/extras": "^9.2.1",
|
9 |
-
"@types/three": "
|
10 |
"clsx": "^2.1.1",
|
|
|
11 |
"tailwind-merge": "^3.3.0",
|
12 |
"three": "^0.177.0",
|
13 |
"threlte-postprocessing": "^0.0.9",
|
@@ -148,7 +149,7 @@
|
|
148 |
|
149 |
"@humanwhocodes/retry": ["@humanwhocodes/[email protected]", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
|
150 |
|
151 |
-
"@iconify/json": ["@iconify/[email protected].
|
152 |
|
153 |
"@iconify/tailwind4": ["@iconify/[email protected]", "", { "dependencies": { "@iconify/types": "^2.0.0", "@iconify/utils": "^2.2.1" }, "peerDependencies": { "tailwindcss": ">= 4" } }, "sha512-43ZXe+bC7CuE2LCgROdqbQeFYJi/J7L/k1UpSy8KDQlWVsWxPzLSWbWhlJx4uRYLOh1NRyw02YlDOgzBOFNd+A=="],
|
154 |
|
@@ -238,7 +239,7 @@
|
|
238 |
|
239 |
"@sveltejs/kit": ["@sveltejs/[email protected]", "", { "dependencies": { "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.1.0", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.3 || ^6.0.0" }, "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-vLbtVwtDcK8LhJKnFkFYwM0uCdFmzioQnif0bjEYH1I24Arz22JPr/hLUiXGVYAwhu8INKx5qrdvr4tHgPwX6w=="],
|
240 |
|
241 |
-
"@sveltejs/vite-plugin-svelte": ["@sveltejs/[email protected]
|
242 |
|
243 |
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/[email protected]", "", { "dependencies": { "debug": "^4.3.7" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^5.0.0", "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw=="],
|
244 |
|
@@ -296,29 +297,29 @@
|
|
296 |
|
297 |
"@types/stats.js": ["@types/[email protected]", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="],
|
298 |
|
299 |
-
"@types/three": ["@types/three@0.
|
300 |
|
301 |
"@types/webxr": ["@types/[email protected]", "", {}, "sha512-Vr6Stjv5jPRqH690f5I5GLjVk8GSsoQSYJ2FVd/3jJF7KaqfwPi3ehfBS96mlQ2kPCwZaX6U0rG2+NGHBKkA/A=="],
|
302 |
|
303 |
-
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/[email protected].
|
304 |
|
305 |
-
"@typescript-eslint/parser": ["@typescript-eslint/[email protected].
|
306 |
|
307 |
-
"@typescript-eslint/project-service": ["@typescript-eslint/[email protected].
|
308 |
|
309 |
-
"@typescript-eslint/scope-manager": ["@typescript-eslint/[email protected].
|
310 |
|
311 |
-
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/[email protected].
|
312 |
|
313 |
-
"@typescript-eslint/type-utils": ["@typescript-eslint/[email protected].
|
314 |
|
315 |
-
"@typescript-eslint/types": ["@typescript-eslint/[email protected].
|
316 |
|
317 |
-
"@typescript-eslint/typescript-estree": ["@typescript-eslint/[email protected].
|
318 |
|
319 |
-
"@typescript-eslint/utils": ["@typescript-eslint/[email protected].
|
320 |
|
321 |
-
"@typescript-eslint/visitor-keys": ["@typescript-eslint/[email protected].
|
322 |
|
323 |
"@webgpu/types": ["@webgpu/[email protected]", "", {}, "sha512-w2HbBvH+qO19SB5pJOJFKs533CdZqxl3fcGonqL321VHkW7W/iBo6H8bjDy6pr/+pbMwIu5dnuaAxH7NxBqUrQ=="],
|
324 |
|
@@ -462,7 +463,7 @@
|
|
462 |
|
463 |
"eslint-config-prettier": ["[email protected]", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw=="],
|
464 |
|
465 |
-
"eslint-plugin-svelte": ["[email protected].
|
466 |
|
467 |
"eslint-scope": ["[email protected]", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ=="],
|
468 |
|
@@ -498,6 +499,8 @@
|
|
498 |
|
499 |
"fdir": ["[email protected]", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-4BG7puHpVsIYxZUbiUE3RqGloLaSSwzYie5jvasC4LWuBWzZawynvYouhjbQKw2JuIGYdm0DzIxl8iVidKlUEw=="],
|
500 |
|
|
|
|
|
501 |
"fflate": ["[email protected]", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
|
502 |
|
503 |
"file-entry-cache": ["[email protected]", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
@@ -726,7 +729,7 @@
|
|
726 |
|
727 |
"supports-color": ["[email protected]", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
728 |
|
729 |
-
"svelte": ["[email protected].
|
730 |
|
731 |
"svelte-check": ["[email protected]", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-e49SU1RStvQhoipkQ/aonDhHnG3qxHSBtNfBRb9pxVXoa+N7qybAo32KgA9wEb2PCYFNaDg7bZCdhLD1vHpdYA=="],
|
732 |
|
@@ -788,7 +791,7 @@
|
|
788 |
|
789 |
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
|
790 |
|
791 |
-
"typescript-eslint": ["[email protected].
|
792 |
|
793 |
"ufo": ["[email protected]", "", {}, "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA=="],
|
794 |
|
@@ -820,7 +823,7 @@
|
|
820 |
|
821 |
"zimmerframe": ["[email protected]", "", {}, "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w=="],
|
822 |
|
823 |
-
"zod": ["[email protected].
|
824 |
|
825 |
"@eslint-community/eslint-utils/eslint-visitor-keys": ["[email protected]", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
826 |
|
|
|
6 |
"dependencies": {
|
7 |
"@threlte/core": "^8.0.4",
|
8 |
"@threlte/extras": "^9.2.1",
|
9 |
+
"@types/three": "0.177.0",
|
10 |
"clsx": "^2.1.1",
|
11 |
+
"feetech.js": "file:packages/feetech.js",
|
12 |
"tailwind-merge": "^3.3.0",
|
13 |
"three": "^0.177.0",
|
14 |
"threlte-postprocessing": "^0.0.9",
|
|
|
149 |
|
150 |
"@humanwhocodes/retry": ["@humanwhocodes/[email protected]", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
|
151 |
|
152 |
+
"@iconify/json": ["@iconify/[email protected].345", "", { "dependencies": { "@iconify/types": "*", "pathe": "^1.1.2" } }, "sha512-cWcTkpSw42OcltXXlLRMp4bnoFEMvEXEIZDPazqqpT7nr4dPN/ztEqOk6T3z0fXrN2E3OEgW0GnHlQqZz4qDgw=="],
|
153 |
|
154 |
"@iconify/tailwind4": ["@iconify/[email protected]", "", { "dependencies": { "@iconify/types": "^2.0.0", "@iconify/utils": "^2.2.1" }, "peerDependencies": { "tailwindcss": ">= 4" } }, "sha512-43ZXe+bC7CuE2LCgROdqbQeFYJi/J7L/k1UpSy8KDQlWVsWxPzLSWbWhlJx4uRYLOh1NRyw02YlDOgzBOFNd+A=="],
|
155 |
|
|
|
239 |
|
240 |
"@sveltejs/kit": ["@sveltejs/[email protected]", "", { "dependencies": { "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.1.0", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.3 || ^6.0.0" }, "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-vLbtVwtDcK8LhJKnFkFYwM0uCdFmzioQnif0bjEYH1I24Arz22JPr/hLUiXGVYAwhu8INKx5qrdvr4tHgPwX6w=="],
|
241 |
|
242 |
+
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@5.1.0", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.1", "deepmerge": "^4.3.1", "kleur": "^4.1.5", "magic-string": "^0.30.17", "vitefu": "^1.0.6" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-wojIS/7GYnJDYIg1higWj2ROA6sSRWvcR1PO/bqEyFr/5UZah26c8Cz4u0NaqjPeVltzsVpt2Tm8d2io0V+4Tw=="],
|
243 |
|
244 |
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/[email protected]", "", { "dependencies": { "debug": "^4.3.7" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^5.0.0", "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw=="],
|
245 |
|
|
|
297 |
|
298 |
"@types/stats.js": ["@types/[email protected]", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="],
|
299 |
|
300 |
+
"@types/three": ["@types/three@0.177.0", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": "*", "@webgpu/types": "*", "fflate": "~0.8.2", "meshoptimizer": "~0.18.1" } }, "sha512-/ZAkn4OLUijKQySNci47lFO+4JLE1TihEjsGWPUT+4jWqxtwOPPEwJV1C3k5MEx0mcBPCdkFjzRzDOnHEI1R+A=="],
|
301 |
|
302 |
"@types/webxr": ["@types/[email protected]", "", {}, "sha512-Vr6Stjv5jPRqH690f5I5GLjVk8GSsoQSYJ2FVd/3jJF7KaqfwPi3ehfBS96mlQ2kPCwZaX6U0rG2+NGHBKkA/A=="],
|
303 |
|
304 |
+
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/[email protected].1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.33.1", "@typescript-eslint/type-utils": "8.33.1", "@typescript-eslint/utils": "8.33.1", "@typescript-eslint/visitor-keys": "8.33.1", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.33.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-TDCXj+YxLgtvxvFlAvpoRv9MAncDLBV2oT9Bd7YBGC/b/sEURoOYuIwLI99rjWOfY3QtDzO+mk0n4AmdFExW8A=="],
|
305 |
|
306 |
+
"@typescript-eslint/parser": ["@typescript-eslint/[email protected].1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.33.1", "@typescript-eslint/types": "8.33.1", "@typescript-eslint/typescript-estree": "8.33.1", "@typescript-eslint/visitor-keys": "8.33.1", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-qwxv6dq682yVvgKKp2qWwLgRbscDAYktPptK4JPojCwwi3R9cwrvIxS4lvBpzmcqzR4bdn54Z0IG1uHFskW4dA=="],
|
307 |
|
308 |
+
"@typescript-eslint/project-service": ["@typescript-eslint/[email protected].1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.33.1", "@typescript-eslint/types": "^8.33.1", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-DZR0efeNklDIHHGRpMpR5gJITQpu6tLr9lDJnKdONTC7vvzOlLAG/wcfxcdxEWrbiZApcoBCzXqU/Z458Za5Iw=="],
|
309 |
|
310 |
+
"@typescript-eslint/scope-manager": ["@typescript-eslint/[email protected].1", "", { "dependencies": { "@typescript-eslint/types": "8.33.1", "@typescript-eslint/visitor-keys": "8.33.1" } }, "sha512-dM4UBtgmzHR9bS0Rv09JST0RcHYearoEoo3pG5B6GoTR9XcyeqX87FEhPo+5kTvVfKCvfHaHrcgeJQc6mrDKrA=="],
|
311 |
|
312 |
+
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/[email protected].1", "", { "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-STAQsGYbHCF0/e+ShUQ4EatXQ7ceh3fBCXkNU7/MZVKulrlq1usH7t2FhxvCpuCi5O5oi1vmVaAjrGeL71OK1g=="],
|
313 |
|
314 |
+
"@typescript-eslint/type-utils": ["@typescript-eslint/[email protected].1", "", { "dependencies": { "@typescript-eslint/typescript-estree": "8.33.1", "@typescript-eslint/utils": "8.33.1", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-1cG37d9xOkhlykom55WVwG2QRNC7YXlxMaMzqw2uPeJixBFfKWZgaP/hjAObqMN/u3fr5BrTwTnc31/L9jQ2ww=="],
|
315 |
|
316 |
+
"@typescript-eslint/types": ["@typescript-eslint/[email protected].1", "", {}, "sha512-xid1WfizGhy/TKMTwhtVOgalHwPtV8T32MS9MaH50Cwvz6x6YqRIPdD2WvW0XaqOzTV9p5xdLY0h/ZusU5Lokg=="],
|
317 |
|
318 |
+
"@typescript-eslint/typescript-estree": ["@typescript-eslint/[email protected].1", "", { "dependencies": { "@typescript-eslint/project-service": "8.33.1", "@typescript-eslint/tsconfig-utils": "8.33.1", "@typescript-eslint/types": "8.33.1", "@typescript-eslint/visitor-keys": "8.33.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-+s9LYcT8LWjdYWu7IWs7FvUxpQ/DGkdjZeE/GGulHvv8rvYwQvVaUZ6DE+j5x/prADUgSbbCWZ2nPI3usuVeOA=="],
|
319 |
|
320 |
+
"@typescript-eslint/utils": ["@typescript-eslint/[email protected].1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.33.1", "@typescript-eslint/types": "8.33.1", "@typescript-eslint/typescript-estree": "8.33.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-52HaBiEQUaRYqAXpfzWSR2U3gxk92Kw006+xZpElaPMg3C4PgM+A5LqwoQI1f9E5aZ/qlxAZxzm42WX+vn92SQ=="],
|
321 |
|
322 |
+
"@typescript-eslint/visitor-keys": ["@typescript-eslint/[email protected].1", "", { "dependencies": { "@typescript-eslint/types": "8.33.1", "eslint-visitor-keys": "^4.2.0" } }, "sha512-3i8NrFcZeeDHJ+7ZUuDkGT+UHq+XoFGsymNK2jZCOHcfEzRQ0BdpRtdpSx/Iyf3MHLWIcLS0COuOPibKQboIiQ=="],
|
323 |
|
324 |
"@webgpu/types": ["@webgpu/[email protected]", "", {}, "sha512-w2HbBvH+qO19SB5pJOJFKs533CdZqxl3fcGonqL321VHkW7W/iBo6H8bjDy6pr/+pbMwIu5dnuaAxH7NxBqUrQ=="],
|
325 |
|
|
|
463 |
|
464 |
"eslint-config-prettier": ["[email protected]", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw=="],
|
465 |
|
466 |
+
"eslint-plugin-svelte": ["[email protected].1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.6.1", "@jridgewell/sourcemap-codec": "^1.5.0", "esutils": "^2.0.3", "globals": "^16.0.0", "known-css-properties": "^0.36.0", "postcss": "^8.4.49", "postcss-load-config": "^3.1.4", "postcss-safe-parser": "^7.0.0", "semver": "^7.6.3", "svelte-eslint-parser": "^1.2.0" }, "peerDependencies": { "eslint": "^8.57.1 || ^9.0.0", "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-mXFulSdD/0/p+zwENjPNsiVwAqmSRp90sy5zvVQBX1yAXhJbdhIn6C/tn8BZYjU94Ia7Y87d1Xdbvi49DeWyHQ=="],
|
467 |
|
468 |
"eslint-scope": ["[email protected]", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ=="],
|
469 |
|
|
|
499 |
|
500 |
"fdir": ["[email protected]", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-4BG7puHpVsIYxZUbiUE3RqGloLaSSwzYie5jvasC4LWuBWzZawynvYouhjbQKw2JuIGYdm0DzIxl8iVidKlUEw=="],
|
501 |
|
502 |
+
"feetech.js": ["feetech.js@file:packages/feetech.js", {}],
|
503 |
+
|
504 |
"fflate": ["[email protected]", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
|
505 |
|
506 |
"file-entry-cache": ["[email protected]", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
|
|
729 |
|
730 |
"supports-color": ["[email protected]", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
731 |
|
732 |
+
"svelte": ["[email protected].14", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "esm-env": "^1.2.1", "esrap": "^1.4.6", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-kRlbhIlMTijbFmVDQFDeKXPLlX1/ovXwV0I162wRqQhRcygaqDIcu1d/Ese3H2uI+yt3uT8E7ndgDthQv5v5BA=="],
|
733 |
|
734 |
"svelte-check": ["[email protected]", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-e49SU1RStvQhoipkQ/aonDhHnG3qxHSBtNfBRb9pxVXoa+N7qybAo32KgA9wEb2PCYFNaDg7bZCdhLD1vHpdYA=="],
|
735 |
|
|
|
791 |
|
792 |
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
|
793 |
|
794 |
+
"typescript-eslint": ["[email protected].1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.33.1", "@typescript-eslint/parser": "8.33.1", "@typescript-eslint/utils": "8.33.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-AgRnV4sKkWOiZ0Kjbnf5ytTJXMUZQ0qhSVdQtDNYLPLnjsATEYhaO94GlRQwi4t4gO8FfjM6NnikHeKjUm8D7A=="],
|
795 |
|
796 |
"ufo": ["[email protected]", "", {}, "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA=="],
|
797 |
|
|
|
823 |
|
824 |
"zimmerframe": ["[email protected]", "", {}, "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w=="],
|
825 |
|
826 |
+
"zod": ["[email protected].49", "", {}, "sha512-JMMPMy9ZBk3XFEdbM3iL1brx4NUSejd6xr3ELrrGEfGb355gjhiAWtG3K5o+AViV/3ZfkIrCzXsZn6SbLwTR8Q=="],
|
827 |
|
828 |
"@eslint-community/eslint-utils/eslint-visitor-keys": ["[email protected]", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
829 |
|
components.json
CHANGED
@@ -5,9 +5,9 @@
|
|
5 |
"baseColor": "stone"
|
6 |
},
|
7 |
"aliases": {
|
8 |
-
"components": "
|
9 |
"utils": "$lib/utils",
|
10 |
-
"ui": "
|
11 |
"hooks": "$lib/hooks",
|
12 |
"lib": "$lib"
|
13 |
},
|
|
|
5 |
"baseColor": "stone"
|
6 |
},
|
7 |
"aliases": {
|
8 |
+
"components": "@/components",
|
9 |
"utils": "$lib/utils",
|
10 |
+
"ui": "@/components/ui",
|
11 |
"hooks": "$lib/hooks",
|
12 |
"lib": "$lib"
|
13 |
},
|
docker-compose.yml
CHANGED
@@ -1,14 +1,14 @@
|
|
1 |
-
version:
|
2 |
|
3 |
services:
|
4 |
lerobot-arena:
|
5 |
build: .
|
6 |
ports:
|
7 |
-
- "7860:7860"
|
8 |
environment:
|
9 |
- NODE_ENV=production
|
10 |
# volumes:
|
11 |
# # Optional: Mount local development files for debugging
|
12 |
# - ./src-python:/home/user/app/src-python
|
13 |
# - ./static-frontend:/home/user/app/static-frontend
|
14 |
-
restart: unless-stopped
|
|
|
1 |
+
version: "3.8"
|
2 |
|
3 |
services:
|
4 |
lerobot-arena:
|
5 |
build: .
|
6 |
ports:
|
7 |
+
- "7860:7860" # FastAPI server serving both frontend and backend
|
8 |
environment:
|
9 |
- NODE_ENV=production
|
10 |
# volumes:
|
11 |
# # Optional: Mount local development files for debugging
|
12 |
# - ./src-python:/home/user/app/src-python
|
13 |
# - ./static-frontend:/home/user/app/static-frontend
|
14 |
+
restart: unless-stopped
|
eslint.config.js
CHANGED
@@ -1,13 +1,13 @@
|
|
1 |
-
import prettier from
|
2 |
-
import js from
|
3 |
-
import { includeIgnoreFile } from
|
4 |
-
import svelte from
|
5 |
-
import globals from
|
6 |
-
import { fileURLToPath } from
|
7 |
-
import ts from
|
8 |
-
import svelteConfig from
|
9 |
|
10 |
-
const gitignorePath = fileURLToPath(new URL(
|
11 |
|
12 |
export default ts.config(
|
13 |
includeIgnoreFile(gitignorePath),
|
@@ -20,14 +20,14 @@ export default ts.config(
|
|
20 |
languageOptions: {
|
21 |
globals: { ...globals.browser, ...globals.node }
|
22 |
},
|
23 |
-
rules: {
|
24 |
},
|
25 |
{
|
26 |
-
files: [
|
27 |
languageOptions: {
|
28 |
parserOptions: {
|
29 |
projectService: true,
|
30 |
-
extraFileExtensions: [
|
31 |
parser: ts.parser,
|
32 |
svelteConfig
|
33 |
}
|
|
|
1 |
+
import prettier from "eslint-config-prettier";
|
2 |
+
import js from "@eslint/js";
|
3 |
+
import { includeIgnoreFile } from "@eslint/compat";
|
4 |
+
import svelte from "eslint-plugin-svelte";
|
5 |
+
import globals from "globals";
|
6 |
+
import { fileURLToPath } from "node:url";
|
7 |
+
import ts from "typescript-eslint";
|
8 |
+
import svelteConfig from "./svelte.config.js";
|
9 |
|
10 |
+
const gitignorePath = fileURLToPath(new URL("./.gitignore", import.meta.url));
|
11 |
|
12 |
export default ts.config(
|
13 |
includeIgnoreFile(gitignorePath),
|
|
|
20 |
languageOptions: {
|
21 |
globals: { ...globals.browser, ...globals.node }
|
22 |
},
|
23 |
+
rules: { "no-undef": "off" }
|
24 |
},
|
25 |
{
|
26 |
+
files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"],
|
27 |
languageOptions: {
|
28 |
parserOptions: {
|
29 |
projectService: true,
|
30 |
+
extraFileExtensions: [".svelte"],
|
31 |
parser: ts.parser,
|
32 |
svelteConfig
|
33 |
}
|
package.json
CHANGED
@@ -52,8 +52,9 @@
|
|
52 |
"dependencies": {
|
53 |
"@threlte/core": "^8.0.4",
|
54 |
"@threlte/extras": "^9.2.1",
|
55 |
-
"@types/three": "
|
56 |
"clsx": "^2.1.1",
|
|
|
57 |
"tailwind-merge": "^3.3.0",
|
58 |
"three": "^0.177.0",
|
59 |
"threlte-postprocessing": "^0.0.9",
|
|
|
52 |
"dependencies": {
|
53 |
"@threlte/core": "^8.0.4",
|
54 |
"@threlte/extras": "^9.2.1",
|
55 |
+
"@types/three": "0.177.0",
|
56 |
"clsx": "^2.1.1",
|
57 |
+
"feetech.js": "file:packages/feetech.js",
|
58 |
"tailwind-merge": "^3.3.0",
|
59 |
"three": "^0.177.0",
|
60 |
"threlte-postprocessing": "^0.0.9",
|
{src/lib → packages}/feetech.js/README.md
RENAMED
@@ -10,7 +10,7 @@ npm install feetech.js
|
|
10 |
```
|
11 |
|
12 |
```javascript
|
13 |
-
import { scsServoSDK } from
|
14 |
|
15 |
await scsServoSDK.connect();
|
16 |
|
@@ -22,5 +22,3 @@ console.log(position); // 1122
|
|
22 |
|
23 |
- simple example: [test.html](./test.html)
|
24 |
- the bambot website: [bambot.org](https://bambot.org)
|
25 |
-
|
26 |
-
|
|
|
10 |
```
|
11 |
|
12 |
```javascript
|
13 |
+
import { scsServoSDK } from "feetech.js";
|
14 |
|
15 |
await scsServoSDK.connect();
|
16 |
|
|
|
22 |
|
23 |
- simple example: [test.html](./test.html)
|
24 |
- the bambot website: [bambot.org](https://bambot.org)
|
|
|
|
packages/feetech.js/index.d.ts
ADDED
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
export type ConnectionOptions = {
|
2 |
+
baudRate?: number;
|
3 |
+
protocolEnd?: number;
|
4 |
+
};
|
5 |
+
|
6 |
+
export type ServoPositions = Map<number, number> | Record<number, number>;
|
7 |
+
export type ServoSpeeds = Map<number, number> | Record<number, number>; // New type alias for speeds
|
8 |
+
|
9 |
+
export interface ScsServoSDK {
|
10 |
+
connect(options?: ConnectionOptions): Promise<true>;
|
11 |
+
disconnect(): Promise<true>;
|
12 |
+
readPosition(servoId: number): Promise<number>;
|
13 |
+
readBaudRate(servoId: number): Promise<number>;
|
14 |
+
readMode(servoId: number): Promise<number>;
|
15 |
+
writePosition(servoId: number, position: number): Promise<"success">;
|
16 |
+
writeTorqueEnable(servoId: number, enable: boolean): Promise<"success">;
|
17 |
+
writeAcceleration(servoId: number, acceleration: number): Promise<"success">;
|
18 |
+
setWheelMode(servoId: number): Promise<"success">;
|
19 |
+
setPositionMode(servoId: number): Promise<"success">;
|
20 |
+
writeWheelSpeed(servoId: number, speed: number): Promise<"success">;
|
21 |
+
syncReadPositions(servoIds: number[]): Promise<Map<number, number>>;
|
22 |
+
syncWritePositions(servoPositions: ServoPositions): Promise<"success">;
|
23 |
+
syncWriteWheelSpeed(servoSpeeds: ServoSpeeds): Promise<"success">; // Add syncWriteWheelSpeed definition
|
24 |
+
setBaudRate(servoId: number, baudRateIndex: number): Promise<"success">;
|
25 |
+
setServoId(currentServoId: number, newServoId: number): Promise<"success">;
|
26 |
+
}
|
27 |
+
|
28 |
+
export const scsServoSDK: ScsServoSDK;
|
29 |
+
|
30 |
+
export interface ScsServoSDKUnlock {
|
31 |
+
connect(options?: ConnectionOptions): Promise<true>;
|
32 |
+
disconnect(): Promise<true>;
|
33 |
+
readPosition(servoId: number): Promise<number>;
|
34 |
+
syncReadPositions(servoIds: number[]): Promise<Map<number, number>>;
|
35 |
+
}
|
36 |
+
|
37 |
+
export const scsServoSDKUnlock: ScsServoSDKUnlock;
|
packages/feetech.js/index.mjs
ADDED
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
// Import all functions from the new scsServoSDK module
|
2 |
+
import {
|
3 |
+
connect,
|
4 |
+
disconnect,
|
5 |
+
readPosition,
|
6 |
+
readBaudRate,
|
7 |
+
readMode,
|
8 |
+
writePosition,
|
9 |
+
writeTorqueEnable,
|
10 |
+
writeAcceleration,
|
11 |
+
setWheelMode,
|
12 |
+
setPositionMode,
|
13 |
+
writeWheelSpeed,
|
14 |
+
syncReadPositions,
|
15 |
+
syncWritePositions,
|
16 |
+
syncWriteWheelSpeed, // Import the new function
|
17 |
+
setBaudRate,
|
18 |
+
setServoId
|
19 |
+
} from "./scsServoSDK.mjs";
|
20 |
+
|
21 |
+
// Create an object to hold all the SCS servo functions
|
22 |
+
export const scsServoSDK = {
|
23 |
+
connect,
|
24 |
+
disconnect,
|
25 |
+
readPosition,
|
26 |
+
readBaudRate,
|
27 |
+
readMode,
|
28 |
+
writePosition,
|
29 |
+
writeTorqueEnable,
|
30 |
+
writeAcceleration,
|
31 |
+
setWheelMode,
|
32 |
+
setPositionMode,
|
33 |
+
writeWheelSpeed,
|
34 |
+
syncReadPositions,
|
35 |
+
syncWritePositions,
|
36 |
+
syncWriteWheelSpeed, // Add the new function to the export
|
37 |
+
setBaudRate,
|
38 |
+
setServoId
|
39 |
+
};
|
40 |
+
|
41 |
+
import {
|
42 |
+
connect as connectUnlock,
|
43 |
+
disconnect as disconnectUnlock,
|
44 |
+
readPosition as readPositionUnlock,
|
45 |
+
syncReadPositions as syncReadPositionsUnlock
|
46 |
+
} from "./scsServoSDKUnlock.mjs";
|
47 |
+
|
48 |
+
export const scsServoSDKUnlock = {
|
49 |
+
connect: connectUnlock,
|
50 |
+
disconnect: disconnectUnlock,
|
51 |
+
readPosition: readPositionUnlock,
|
52 |
+
syncReadPositions: syncReadPositionsUnlock
|
53 |
+
};
|
54 |
+
|
55 |
+
// Future: You can add exports for other servo types here, e.g.:
|
56 |
+
// export { stsServoSDK } from './stsServoSDK.mjs';
|
57 |
+
// export { smsServoSDK } from './smsServoSDK.mjs';
|
packages/feetech.js/lowLevelSDK.mjs
ADDED
@@ -0,0 +1,1232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
// Constants
|
2 |
+
export const BROADCAST_ID = 0xfe; // 254
|
3 |
+
export const MAX_ID = 0xfc; // 252
|
4 |
+
|
5 |
+
// Protocol instructions
|
6 |
+
export const INST_PING = 1;
|
7 |
+
export const INST_READ = 2;
|
8 |
+
export const INST_WRITE = 3;
|
9 |
+
export const INST_REG_WRITE = 4;
|
10 |
+
export const INST_ACTION = 5;
|
11 |
+
export const INST_SYNC_WRITE = 131; // 0x83
|
12 |
+
export const INST_SYNC_READ = 130; // 0x82
|
13 |
+
export const INST_STATUS = 85; // 0x55, status packet instruction (0x55)
|
14 |
+
|
15 |
+
// Communication results
|
16 |
+
export const COMM_SUCCESS = 0; // tx or rx packet communication success
|
17 |
+
export const COMM_PORT_BUSY = -1; // Port is busy (in use)
|
18 |
+
export const COMM_TX_FAIL = -2; // Failed transmit instruction packet
|
19 |
+
export const COMM_RX_FAIL = -3; // Failed get status packet
|
20 |
+
export const COMM_TX_ERROR = -4; // Incorrect instruction packet
|
21 |
+
export const COMM_RX_WAITING = -5; // Now receiving status packet
|
22 |
+
export const COMM_RX_TIMEOUT = -6; // There is no status packet
|
23 |
+
export const COMM_RX_CORRUPT = -7; // Incorrect status packet
|
24 |
+
export const COMM_NOT_AVAILABLE = -9;
|
25 |
+
|
26 |
+
// Packet constants
|
27 |
+
export const TXPACKET_MAX_LEN = 250;
|
28 |
+
export const RXPACKET_MAX_LEN = 250;
|
29 |
+
|
30 |
+
// Protocol Packet positions
|
31 |
+
export const PKT_HEADER0 = 0;
|
32 |
+
export const PKT_HEADER1 = 1;
|
33 |
+
export const PKT_ID = 2;
|
34 |
+
export const PKT_LENGTH = 3;
|
35 |
+
export const PKT_INSTRUCTION = 4;
|
36 |
+
export const PKT_ERROR = 4;
|
37 |
+
export const PKT_PARAMETER0 = 5;
|
38 |
+
|
39 |
+
// Protocol Error bits
|
40 |
+
export const ERRBIT_VOLTAGE = 1;
|
41 |
+
export const ERRBIT_ANGLE = 2;
|
42 |
+
export const ERRBIT_OVERHEAT = 4;
|
43 |
+
export const ERRBIT_OVERELE = 8;
|
44 |
+
export const ERRBIT_OVERLOAD = 32;
|
45 |
+
|
46 |
+
// Default settings
|
47 |
+
const DEFAULT_BAUDRATE = 1000000;
|
48 |
+
const LATENCY_TIMER = 16;
|
49 |
+
|
50 |
+
// Global protocol end state
|
51 |
+
let SCS_END = 0; // (STS/SMS=0, SCS=1)
|
52 |
+
|
53 |
+
// Utility functions for handling word operations
|
54 |
+
export function SCS_LOWORD(l) {
|
55 |
+
return l & 0xffff;
|
56 |
+
}
|
57 |
+
|
58 |
+
export function SCS_HIWORD(l) {
|
59 |
+
return (l >> 16) & 0xffff;
|
60 |
+
}
|
61 |
+
|
62 |
+
export function SCS_LOBYTE(w) {
|
63 |
+
if (SCS_END === 0) {
|
64 |
+
return w & 0xff;
|
65 |
+
} else {
|
66 |
+
return (w >> 8) & 0xff;
|
67 |
+
}
|
68 |
+
}
|
69 |
+
|
70 |
+
export function SCS_HIBYTE(w) {
|
71 |
+
if (SCS_END === 0) {
|
72 |
+
return (w >> 8) & 0xff;
|
73 |
+
} else {
|
74 |
+
return w & 0xff;
|
75 |
+
}
|
76 |
+
}
|
77 |
+
|
78 |
+
export function SCS_MAKEWORD(a, b) {
|
79 |
+
if (SCS_END === 0) {
|
80 |
+
return (a & 0xff) | ((b & 0xff) << 8);
|
81 |
+
} else {
|
82 |
+
return (b & 0xff) | ((a & 0xff) << 8);
|
83 |
+
}
|
84 |
+
}
|
85 |
+
|
86 |
+
export function SCS_MAKEDWORD(a, b) {
|
87 |
+
return (a & 0xffff) | ((b & 0xffff) << 16);
|
88 |
+
}
|
89 |
+
|
90 |
+
export function SCS_TOHOST(a, b) {
|
91 |
+
if (a & (1 << b)) {
|
92 |
+
return -(a & ~(1 << b));
|
93 |
+
} else {
|
94 |
+
return a;
|
95 |
+
}
|
96 |
+
}
|
97 |
+
|
98 |
+
export class PortHandler {
|
99 |
+
constructor() {
|
100 |
+
this.port = null;
|
101 |
+
this.reader = null;
|
102 |
+
this.writer = null;
|
103 |
+
this.isOpen = false;
|
104 |
+
this.isUsing = false;
|
105 |
+
this.baudrate = DEFAULT_BAUDRATE;
|
106 |
+
this.packetStartTime = 0;
|
107 |
+
this.packetTimeout = 0;
|
108 |
+
this.txTimePerByte = 0;
|
109 |
+
}
|
110 |
+
|
111 |
+
async requestPort() {
|
112 |
+
try {
|
113 |
+
this.port = await navigator.serial.requestPort();
|
114 |
+
return true;
|
115 |
+
} catch (err) {
|
116 |
+
console.error("Error requesting serial port:", err);
|
117 |
+
return false;
|
118 |
+
}
|
119 |
+
}
|
120 |
+
|
121 |
+
async openPort() {
|
122 |
+
if (!this.port) {
|
123 |
+
return false;
|
124 |
+
}
|
125 |
+
|
126 |
+
try {
|
127 |
+
await this.port.open({ baudRate: this.baudrate });
|
128 |
+
this.reader = this.port.readable.getReader();
|
129 |
+
this.writer = this.port.writable.getWriter();
|
130 |
+
this.isOpen = true;
|
131 |
+
this.txTimePerByte = (1000.0 / this.baudrate) * 10.0;
|
132 |
+
return true;
|
133 |
+
} catch (err) {
|
134 |
+
console.error("Error opening port:", err);
|
135 |
+
return false;
|
136 |
+
}
|
137 |
+
}
|
138 |
+
|
139 |
+
async closePort() {
|
140 |
+
if (this.reader) {
|
141 |
+
await this.reader.releaseLock();
|
142 |
+
this.reader = null;
|
143 |
+
}
|
144 |
+
|
145 |
+
if (this.writer) {
|
146 |
+
await this.writer.releaseLock();
|
147 |
+
this.writer = null;
|
148 |
+
}
|
149 |
+
|
150 |
+
if (this.port && this.isOpen) {
|
151 |
+
await this.port.close();
|
152 |
+
this.isOpen = false;
|
153 |
+
}
|
154 |
+
}
|
155 |
+
|
156 |
+
async clearPort() {
|
157 |
+
if (this.reader) {
|
158 |
+
await this.reader.releaseLock();
|
159 |
+
this.reader = this.port.readable.getReader();
|
160 |
+
}
|
161 |
+
}
|
162 |
+
|
163 |
+
setBaudRate(baudrate) {
|
164 |
+
this.baudrate = baudrate;
|
165 |
+
this.txTimePerByte = (1000.0 / this.baudrate) * 10.0;
|
166 |
+
return true;
|
167 |
+
}
|
168 |
+
|
169 |
+
getBaudRate() {
|
170 |
+
return this.baudrate;
|
171 |
+
}
|
172 |
+
|
173 |
+
async writePort(data) {
|
174 |
+
if (!this.isOpen || !this.writer) {
|
175 |
+
return 0;
|
176 |
+
}
|
177 |
+
|
178 |
+
try {
|
179 |
+
await this.writer.write(new Uint8Array(data));
|
180 |
+
return data.length;
|
181 |
+
} catch (err) {
|
182 |
+
console.error("Error writing to port:", err);
|
183 |
+
return 0;
|
184 |
+
}
|
185 |
+
}
|
186 |
+
|
187 |
+
async readPort(length) {
|
188 |
+
if (!this.isOpen || !this.reader) {
|
189 |
+
return [];
|
190 |
+
}
|
191 |
+
|
192 |
+
try {
|
193 |
+
// Increase timeout for more reliable data reception
|
194 |
+
const timeoutMs = 500;
|
195 |
+
let totalBytes = [];
|
196 |
+
const startTime = performance.now();
|
197 |
+
|
198 |
+
// Continue reading until we get enough bytes or timeout
|
199 |
+
while (totalBytes.length < length) {
|
200 |
+
// Create a timeout promise
|
201 |
+
const timeoutPromise = new Promise((resolve) => {
|
202 |
+
setTimeout(() => resolve({ value: new Uint8Array(), done: false, timeout: true }), 100); // Short internal timeout
|
203 |
+
});
|
204 |
+
|
205 |
+
// Race between reading and timeout
|
206 |
+
const result = await Promise.race([this.reader.read(), timeoutPromise]);
|
207 |
+
|
208 |
+
if (result.timeout) {
|
209 |
+
// Internal timeout - check if we've exceeded total timeout
|
210 |
+
if (performance.now() - startTime > timeoutMs) {
|
211 |
+
console.log(`readPort total timeout after ${timeoutMs}ms`);
|
212 |
+
break;
|
213 |
+
}
|
214 |
+
continue; // Try reading again
|
215 |
+
}
|
216 |
+
|
217 |
+
if (result.done) {
|
218 |
+
console.log("Reader done, stream closed");
|
219 |
+
break;
|
220 |
+
}
|
221 |
+
|
222 |
+
if (result.value.length === 0) {
|
223 |
+
// If there's no data but we haven't timed out yet, wait briefly and try again
|
224 |
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
225 |
+
|
226 |
+
// Check if we've exceeded total timeout
|
227 |
+
if (performance.now() - startTime > timeoutMs) {
|
228 |
+
console.log(`readPort total timeout after ${timeoutMs}ms`);
|
229 |
+
break;
|
230 |
+
}
|
231 |
+
continue;
|
232 |
+
}
|
233 |
+
|
234 |
+
// Add received bytes to our total
|
235 |
+
const newData = Array.from(result.value);
|
236 |
+
totalBytes.push(...newData);
|
237 |
+
console.log(
|
238 |
+
`Read ${newData.length} bytes:`,
|
239 |
+
newData.map((b) => b.toString(16).padStart(2, "0")).join(" ")
|
240 |
+
);
|
241 |
+
|
242 |
+
// If we've got enough data, we can stop
|
243 |
+
if (totalBytes.length >= length) {
|
244 |
+
break;
|
245 |
+
}
|
246 |
+
}
|
247 |
+
|
248 |
+
return totalBytes;
|
249 |
+
} catch (err) {
|
250 |
+
console.error("Error reading from port:", err);
|
251 |
+
return [];
|
252 |
+
}
|
253 |
+
}
|
254 |
+
|
255 |
+
setPacketTimeout(packetLength) {
|
256 |
+
this.packetStartTime = this.getCurrentTime();
|
257 |
+
this.packetTimeout = this.txTimePerByte * packetLength + LATENCY_TIMER * 2.0 + 2.0;
|
258 |
+
}
|
259 |
+
|
260 |
+
setPacketTimeoutMillis(msec) {
|
261 |
+
this.packetStartTime = this.getCurrentTime();
|
262 |
+
this.packetTimeout = msec;
|
263 |
+
}
|
264 |
+
|
265 |
+
isPacketTimeout() {
|
266 |
+
if (this.getTimeSinceStart() > this.packetTimeout) {
|
267 |
+
this.packetTimeout = 0;
|
268 |
+
return true;
|
269 |
+
}
|
270 |
+
return false;
|
271 |
+
}
|
272 |
+
|
273 |
+
getCurrentTime() {
|
274 |
+
return performance.now();
|
275 |
+
}
|
276 |
+
|
277 |
+
getTimeSinceStart() {
|
278 |
+
const timeSince = this.getCurrentTime() - this.packetStartTime;
|
279 |
+
if (timeSince < 0.0) {
|
280 |
+
this.packetStartTime = this.getCurrentTime();
|
281 |
+
}
|
282 |
+
return timeSince;
|
283 |
+
}
|
284 |
+
}
|
285 |
+
|
286 |
+
export class PacketHandler {
|
287 |
+
constructor(protocolEnd = 0) {
|
288 |
+
SCS_END = protocolEnd;
|
289 |
+
console.log(`PacketHandler initialized with protocol_end=${protocolEnd} (STS/SMS=0, SCS=1)`);
|
290 |
+
}
|
291 |
+
|
292 |
+
getProtocolVersion() {
|
293 |
+
return 1.0;
|
294 |
+
}
|
295 |
+
|
296 |
+
// 获取当前协议端设置的方法
|
297 |
+
getProtocolEnd() {
|
298 |
+
return SCS_END;
|
299 |
+
}
|
300 |
+
|
301 |
+
getTxRxResult(result) {
|
302 |
+
if (result === COMM_SUCCESS) {
|
303 |
+
return "[TxRxResult] Communication success!";
|
304 |
+
} else if (result === COMM_PORT_BUSY) {
|
305 |
+
return "[TxRxResult] Port is in use!";
|
306 |
+
} else if (result === COMM_TX_FAIL) {
|
307 |
+
return "[TxRxResult] Failed transmit instruction packet!";
|
308 |
+
} else if (result === COMM_RX_FAIL) {
|
309 |
+
return "[TxRxResult] Failed get status packet from device!";
|
310 |
+
} else if (result === COMM_TX_ERROR) {
|
311 |
+
return "[TxRxResult] Incorrect instruction packet!";
|
312 |
+
} else if (result === COMM_RX_WAITING) {
|
313 |
+
return "[TxRxResult] Now receiving status packet!";
|
314 |
+
} else if (result === COMM_RX_TIMEOUT) {
|
315 |
+
return "[TxRxResult] There is no status packet!";
|
316 |
+
} else if (result === COMM_RX_CORRUPT) {
|
317 |
+
return "[TxRxResult] Incorrect status packet!";
|
318 |
+
} else if (result === COMM_NOT_AVAILABLE) {
|
319 |
+
return "[TxRxResult] Protocol does not support this function!";
|
320 |
+
} else {
|
321 |
+
return "";
|
322 |
+
}
|
323 |
+
}
|
324 |
+
|
325 |
+
getRxPacketError(error) {
|
326 |
+
if (error & ERRBIT_VOLTAGE) {
|
327 |
+
return "[RxPacketError] Input voltage error!";
|
328 |
+
}
|
329 |
+
if (error & ERRBIT_ANGLE) {
|
330 |
+
return "[RxPacketError] Angle sen error!";
|
331 |
+
}
|
332 |
+
if (error & ERRBIT_OVERHEAT) {
|
333 |
+
return "[RxPacketError] Overheat error!";
|
334 |
+
}
|
335 |
+
if (error & ERRBIT_OVERELE) {
|
336 |
+
return "[RxPacketError] OverEle error!";
|
337 |
+
}
|
338 |
+
if (error & ERRBIT_OVERLOAD) {
|
339 |
+
return "[RxPacketError] Overload error!";
|
340 |
+
}
|
341 |
+
return "";
|
342 |
+
}
|
343 |
+
|
344 |
+
async txPacket(port, txpacket) {
|
345 |
+
let checksum = 0;
|
346 |
+
const totalPacketLength = txpacket[PKT_LENGTH] + 4; // 4: HEADER0 HEADER1 ID LENGTH
|
347 |
+
|
348 |
+
if (port.isUsing) {
|
349 |
+
return COMM_PORT_BUSY;
|
350 |
+
}
|
351 |
+
port.isUsing = true;
|
352 |
+
|
353 |
+
// Check max packet length
|
354 |
+
if (totalPacketLength > TXPACKET_MAX_LEN) {
|
355 |
+
port.isUsing = false;
|
356 |
+
return COMM_TX_ERROR;
|
357 |
+
}
|
358 |
+
|
359 |
+
// Make packet header
|
360 |
+
txpacket[PKT_HEADER0] = 0xff;
|
361 |
+
txpacket[PKT_HEADER1] = 0xff;
|
362 |
+
|
363 |
+
// Add checksum to packet
|
364 |
+
for (let idx = 2; idx < totalPacketLength - 1; idx++) {
|
365 |
+
checksum += txpacket[idx];
|
366 |
+
}
|
367 |
+
|
368 |
+
txpacket[totalPacketLength - 1] = ~checksum & 0xff;
|
369 |
+
|
370 |
+
// TX packet
|
371 |
+
await port.clearPort();
|
372 |
+
const writtenPacketLength = await port.writePort(txpacket);
|
373 |
+
if (totalPacketLength !== writtenPacketLength) {
|
374 |
+
port.isUsing = false;
|
375 |
+
return COMM_TX_FAIL;
|
376 |
+
}
|
377 |
+
|
378 |
+
return COMM_SUCCESS;
|
379 |
+
}
|
380 |
+
|
381 |
+
async rxPacket(port) {
|
382 |
+
let rxpacket = [];
|
383 |
+
let result = COMM_RX_FAIL;
|
384 |
+
|
385 |
+
let waitLength = 6; // minimum length (HEADER0 HEADER1 ID LENGTH)
|
386 |
+
|
387 |
+
while (true) {
|
388 |
+
const data = await port.readPort(waitLength - rxpacket.length);
|
389 |
+
rxpacket.push(...data);
|
390 |
+
|
391 |
+
if (rxpacket.length >= waitLength) {
|
392 |
+
// Find packet header
|
393 |
+
let headerIndex = -1;
|
394 |
+
for (let i = 0; i < rxpacket.length - 1; i++) {
|
395 |
+
if (rxpacket[i] === 0xff && rxpacket[i + 1] === 0xff) {
|
396 |
+
headerIndex = i;
|
397 |
+
break;
|
398 |
+
}
|
399 |
+
}
|
400 |
+
|
401 |
+
if (headerIndex === 0) {
|
402 |
+
// Found at the beginning of the packet
|
403 |
+
if (rxpacket[PKT_ID] > 0xfd || rxpacket[PKT_LENGTH] > RXPACKET_MAX_LEN) {
|
404 |
+
// Invalid ID or length
|
405 |
+
rxpacket.shift();
|
406 |
+
continue;
|
407 |
+
}
|
408 |
+
|
409 |
+
// Recalculate expected packet length
|
410 |
+
if (waitLength !== rxpacket[PKT_LENGTH] + PKT_LENGTH + 1) {
|
411 |
+
waitLength = rxpacket[PKT_LENGTH] + PKT_LENGTH + 1;
|
412 |
+
continue;
|
413 |
+
}
|
414 |
+
|
415 |
+
if (rxpacket.length < waitLength) {
|
416 |
+
// Check timeout
|
417 |
+
if (port.isPacketTimeout()) {
|
418 |
+
result = rxpacket.length === 0 ? COMM_RX_TIMEOUT : COMM_RX_CORRUPT;
|
419 |
+
break;
|
420 |
+
}
|
421 |
+
continue;
|
422 |
+
}
|
423 |
+
|
424 |
+
// Calculate checksum
|
425 |
+
let checksum = 0;
|
426 |
+
for (let i = 2; i < waitLength - 1; i++) {
|
427 |
+
checksum += rxpacket[i];
|
428 |
+
}
|
429 |
+
checksum = ~checksum & 0xff;
|
430 |
+
|
431 |
+
// Verify checksum
|
432 |
+
if (rxpacket[waitLength - 1] === checksum) {
|
433 |
+
result = COMM_SUCCESS;
|
434 |
+
} else {
|
435 |
+
result = COMM_RX_CORRUPT;
|
436 |
+
}
|
437 |
+
break;
|
438 |
+
} else if (headerIndex > 0) {
|
439 |
+
// Remove unnecessary bytes before header
|
440 |
+
rxpacket = rxpacket.slice(headerIndex);
|
441 |
+
continue;
|
442 |
+
}
|
443 |
+
}
|
444 |
+
|
445 |
+
// Check timeout
|
446 |
+
if (port.isPacketTimeout()) {
|
447 |
+
result = rxpacket.length === 0 ? COMM_RX_TIMEOUT : COMM_RX_CORRUPT;
|
448 |
+
break;
|
449 |
+
}
|
450 |
+
}
|
451 |
+
|
452 |
+
if (result !== COMM_SUCCESS) {
|
453 |
+
console.log(
|
454 |
+
`rxPacket result: ${result}, packet: ${rxpacket.map((b) => "0x" + b.toString(16).padStart(2, "0")).join(" ")}`
|
455 |
+
);
|
456 |
+
} else {
|
457 |
+
console.debug(
|
458 |
+
`rxPacket successful: ${rxpacket.map((b) => "0x" + b.toString(16).padStart(2, "0")).join(" ")}`
|
459 |
+
);
|
460 |
+
}
|
461 |
+
return [rxpacket, result];
|
462 |
+
}
|
463 |
+
|
464 |
+
async txRxPacket(port, txpacket) {
|
465 |
+
let rxpacket = null;
|
466 |
+
let error = 0;
|
467 |
+
let result = COMM_TX_FAIL;
|
468 |
+
|
469 |
+
try {
|
470 |
+
// Check if port is already in use
|
471 |
+
if (port.isUsing) {
|
472 |
+
console.log("Port is busy, cannot start new transaction");
|
473 |
+
return [rxpacket, COMM_PORT_BUSY, error];
|
474 |
+
}
|
475 |
+
|
476 |
+
// TX packet
|
477 |
+
console.log(
|
478 |
+
"Sending packet:",
|
479 |
+
txpacket.map((b) => "0x" + b.toString(16).padStart(2, "0")).join(" ")
|
480 |
+
);
|
481 |
+
|
482 |
+
// Remove retry logic and just send once
|
483 |
+
result = await this.txPacket(port, txpacket);
|
484 |
+
console.log(`TX result: ${result}`);
|
485 |
+
|
486 |
+
if (result !== COMM_SUCCESS) {
|
487 |
+
console.log(`TX failed with result: ${result}`);
|
488 |
+
port.isUsing = false; // Important: Release the port on TX failure
|
489 |
+
return [rxpacket, result, error];
|
490 |
+
}
|
491 |
+
|
492 |
+
// If ID is broadcast, no need to wait for status packet
|
493 |
+
if (txpacket[PKT_ID] === BROADCAST_ID) {
|
494 |
+
port.isUsing = false;
|
495 |
+
return [rxpacket, result, error];
|
496 |
+
}
|
497 |
+
|
498 |
+
// Set packet timeout
|
499 |
+
if (txpacket[PKT_INSTRUCTION] === INST_READ) {
|
500 |
+
const length = txpacket[PKT_PARAMETER0 + 1];
|
501 |
+
// For READ instructions, we expect response to include the data
|
502 |
+
port.setPacketTimeout(length + 10); // Add extra buffer
|
503 |
+
console.log(`Set READ packet timeout for ${length + 10} bytes`);
|
504 |
+
} else {
|
505 |
+
// For other instructions, we expect a status packet
|
506 |
+
port.setPacketTimeout(10); // HEADER0 HEADER1 ID LENGTH ERROR CHECKSUM + buffer
|
507 |
+
console.log(`Set standard packet timeout for 10 bytes`);
|
508 |
+
}
|
509 |
+
|
510 |
+
// RX packet - no retries, just attempt once
|
511 |
+
console.log(`Receiving packet`);
|
512 |
+
|
513 |
+
// Clear port before receiving to ensure clean state
|
514 |
+
await port.clearPort();
|
515 |
+
|
516 |
+
const [rxpacketResult, resultRx] = await this.rxPacket(port);
|
517 |
+
rxpacket = rxpacketResult;
|
518 |
+
|
519 |
+
// Check if received packet is valid
|
520 |
+
if (resultRx !== COMM_SUCCESS) {
|
521 |
+
console.log(`Rx failed with result: ${resultRx}`);
|
522 |
+
port.isUsing = false;
|
523 |
+
return [rxpacket, resultRx, error];
|
524 |
+
}
|
525 |
+
|
526 |
+
// Verify packet structure
|
527 |
+
if (rxpacket.length < 6) {
|
528 |
+
console.log(`Received packet too short (${rxpacket.length} bytes)`);
|
529 |
+
port.isUsing = false;
|
530 |
+
return [rxpacket, COMM_RX_CORRUPT, error];
|
531 |
+
}
|
532 |
+
|
533 |
+
// Verify packet ID matches the sent ID
|
534 |
+
if (rxpacket[PKT_ID] !== txpacket[PKT_ID]) {
|
535 |
+
console.log(
|
536 |
+
`Received packet ID (${rxpacket[PKT_ID]}) doesn't match sent ID (${txpacket[PKT_ID]})`
|
537 |
+
);
|
538 |
+
port.isUsing = false;
|
539 |
+
return [rxpacket, COMM_RX_CORRUPT, error];
|
540 |
+
}
|
541 |
+
|
542 |
+
// Packet looks valid
|
543 |
+
error = rxpacket[PKT_ERROR];
|
544 |
+
port.isUsing = false; // Release port on success
|
545 |
+
return [rxpacket, resultRx, error];
|
546 |
+
} catch (err) {
|
547 |
+
console.error("Exception in txRxPacket:", err);
|
548 |
+
port.isUsing = false; // Release port on exception
|
549 |
+
return [rxpacket, COMM_RX_FAIL, error];
|
550 |
+
}
|
551 |
+
}
|
552 |
+
|
553 |
+
async ping(port, scsId) {
|
554 |
+
let modelNumber = 0;
|
555 |
+
let error = 0;
|
556 |
+
|
557 |
+
try {
|
558 |
+
if (scsId >= BROADCAST_ID) {
|
559 |
+
console.log(`Cannot ping broadcast ID ${scsId}`);
|
560 |
+
return [modelNumber, COMM_NOT_AVAILABLE, error];
|
561 |
+
}
|
562 |
+
|
563 |
+
const txpacket = new Array(6).fill(0);
|
564 |
+
txpacket[PKT_ID] = scsId;
|
565 |
+
txpacket[PKT_LENGTH] = 2;
|
566 |
+
txpacket[PKT_INSTRUCTION] = INST_PING;
|
567 |
+
|
568 |
+
console.log(`Pinging servo ID ${scsId}...`);
|
569 |
+
|
570 |
+
// 发送ping指令并获取响应
|
571 |
+
const [rxpacket, result, err] = await this.txRxPacket(port, txpacket);
|
572 |
+
error = err;
|
573 |
+
|
574 |
+
// 与Python SDK保持一致:如果ping成功,尝试��取地址3的型号信息
|
575 |
+
if (result === COMM_SUCCESS) {
|
576 |
+
console.log(`Ping to servo ID ${scsId} succeeded, reading model number from address 3`);
|
577 |
+
// 读取地址3的型号信息(2字节)
|
578 |
+
const [data, dataResult, dataError] = await this.readTxRx(port, scsId, 3, 2);
|
579 |
+
|
580 |
+
if (dataResult === COMM_SUCCESS && data && data.length >= 2) {
|
581 |
+
modelNumber = SCS_MAKEWORD(data[0], data[1]);
|
582 |
+
console.log(`Model number read: ${modelNumber}`);
|
583 |
+
} else {
|
584 |
+
console.log(`Could not read model number: ${this.getTxRxResult(dataResult)}`);
|
585 |
+
}
|
586 |
+
} else {
|
587 |
+
console.log(`Ping failed with result: ${result}, error: ${error}`);
|
588 |
+
}
|
589 |
+
|
590 |
+
return [modelNumber, result, error];
|
591 |
+
} catch (error) {
|
592 |
+
console.error(`Exception in ping():`, error);
|
593 |
+
return [0, COMM_RX_FAIL, 0];
|
594 |
+
}
|
595 |
+
}
|
596 |
+
|
597 |
+
// Read methods
|
598 |
+
async readTxRx(port, scsId, address, length) {
|
599 |
+
if (scsId >= BROADCAST_ID) {
|
600 |
+
console.log("Cannot read from broadcast ID");
|
601 |
+
return [[], COMM_NOT_AVAILABLE, 0];
|
602 |
+
}
|
603 |
+
|
604 |
+
// Create read packet
|
605 |
+
const txpacket = new Array(8).fill(0);
|
606 |
+
txpacket[PKT_ID] = scsId;
|
607 |
+
txpacket[PKT_LENGTH] = 4;
|
608 |
+
txpacket[PKT_INSTRUCTION] = INST_READ;
|
609 |
+
txpacket[PKT_PARAMETER0] = address;
|
610 |
+
txpacket[PKT_PARAMETER0 + 1] = length;
|
611 |
+
|
612 |
+
console.log(`Reading ${length} bytes from address ${address} for servo ID ${scsId}`);
|
613 |
+
|
614 |
+
// Send packet and get response
|
615 |
+
const [rxpacket, result, error] = await this.txRxPacket(port, txpacket);
|
616 |
+
|
617 |
+
// Process the result
|
618 |
+
if (result !== COMM_SUCCESS) {
|
619 |
+
console.log(`Read failed with result: ${result}, error: ${error}`);
|
620 |
+
return [[], result, error];
|
621 |
+
}
|
622 |
+
|
623 |
+
if (!rxpacket || rxpacket.length < PKT_PARAMETER0 + length) {
|
624 |
+
console.log(
|
625 |
+
`Invalid response packet: expected at least ${PKT_PARAMETER0 + length} bytes, got ${rxpacket ? rxpacket.length : 0}`
|
626 |
+
);
|
627 |
+
return [[], COMM_RX_CORRUPT, error];
|
628 |
+
}
|
629 |
+
|
630 |
+
// Extract data from response
|
631 |
+
const data = [];
|
632 |
+
console.log(
|
633 |
+
`Response packet length: ${rxpacket.length}, extracting ${length} bytes from offset ${PKT_PARAMETER0}`
|
634 |
+
);
|
635 |
+
console.log(
|
636 |
+
`Response data bytes: ${rxpacket
|
637 |
+
.slice(PKT_PARAMETER0, PKT_PARAMETER0 + length)
|
638 |
+
.map((b) => "0x" + b.toString(16).padStart(2, "0"))
|
639 |
+
.join(" ")}`
|
640 |
+
);
|
641 |
+
|
642 |
+
for (let i = 0; i < length; i++) {
|
643 |
+
data.push(rxpacket[PKT_PARAMETER0 + i]);
|
644 |
+
}
|
645 |
+
|
646 |
+
console.log(
|
647 |
+
`Successfully read ${length} bytes: ${data.map((b) => "0x" + b.toString(16).padStart(2, "0")).join(" ")}`
|
648 |
+
);
|
649 |
+
return [data, result, error];
|
650 |
+
}
|
651 |
+
|
652 |
+
async read1ByteTxRx(port, scsId, address) {
|
653 |
+
const [data, result, error] = await this.readTxRx(port, scsId, address, 1);
|
654 |
+
const value = data.length > 0 ? data[0] : 0;
|
655 |
+
return [value, result, error];
|
656 |
+
}
|
657 |
+
|
658 |
+
async read2ByteTxRx(port, scsId, address) {
|
659 |
+
const [data, result, error] = await this.readTxRx(port, scsId, address, 2);
|
660 |
+
|
661 |
+
let value = 0;
|
662 |
+
if (data.length >= 2) {
|
663 |
+
value = SCS_MAKEWORD(data[0], data[1]);
|
664 |
+
}
|
665 |
+
|
666 |
+
return [value, result, error];
|
667 |
+
}
|
668 |
+
|
669 |
+
async read4ByteTxRx(port, scsId, address) {
|
670 |
+
const [data, result, error] = await this.readTxRx(port, scsId, address, 4);
|
671 |
+
|
672 |
+
let value = 0;
|
673 |
+
if (data.length >= 4) {
|
674 |
+
const loword = SCS_MAKEWORD(data[0], data[1]);
|
675 |
+
const hiword = SCS_MAKEWORD(data[2], data[3]);
|
676 |
+
value = SCS_MAKEDWORD(loword, hiword);
|
677 |
+
|
678 |
+
console.log(
|
679 |
+
`read4ByteTxRx: data=${data.map((b) => "0x" + b.toString(16).padStart(2, "0")).join(" ")}`
|
680 |
+
);
|
681 |
+
console.log(
|
682 |
+
` loword=${loword} (0x${loword.toString(16)}), hiword=${hiword} (0x${hiword.toString(16)})`
|
683 |
+
);
|
684 |
+
console.log(` value=${value} (0x${value.toString(16)})`);
|
685 |
+
}
|
686 |
+
|
687 |
+
return [value, result, error];
|
688 |
+
}
|
689 |
+
|
690 |
+
// Write methods
|
691 |
+
async writeTxRx(port, scsId, address, length, data) {
|
692 |
+
if (scsId >= BROADCAST_ID) {
|
693 |
+
return [COMM_NOT_AVAILABLE, 0];
|
694 |
+
}
|
695 |
+
|
696 |
+
// Create write packet
|
697 |
+
const txpacket = new Array(length + 7).fill(0);
|
698 |
+
txpacket[PKT_ID] = scsId;
|
699 |
+
txpacket[PKT_LENGTH] = length + 3;
|
700 |
+
txpacket[PKT_INSTRUCTION] = INST_WRITE;
|
701 |
+
txpacket[PKT_PARAMETER0] = address;
|
702 |
+
|
703 |
+
// Add data
|
704 |
+
for (let i = 0; i < length; i++) {
|
705 |
+
txpacket[PKT_PARAMETER0 + 1 + i] = data[i] & 0xff;
|
706 |
+
}
|
707 |
+
|
708 |
+
// Send packet and get response
|
709 |
+
const [rxpacket, result, error] = await this.txRxPacket(port, txpacket);
|
710 |
+
|
711 |
+
return [result, error];
|
712 |
+
}
|
713 |
+
|
714 |
+
async write1ByteTxRx(port, scsId, address, data) {
|
715 |
+
const dataArray = [data & 0xff];
|
716 |
+
return await this.writeTxRx(port, scsId, address, 1, dataArray);
|
717 |
+
}
|
718 |
+
|
719 |
+
async write2ByteTxRx(port, scsId, address, data) {
|
720 |
+
const dataArray = [SCS_LOBYTE(data), SCS_HIBYTE(data)];
|
721 |
+
return await this.writeTxRx(port, scsId, address, 2, dataArray);
|
722 |
+
}
|
723 |
+
|
724 |
+
async write4ByteTxRx(port, scsId, address, data) {
|
725 |
+
const dataArray = [
|
726 |
+
SCS_LOBYTE(SCS_LOWORD(data)),
|
727 |
+
SCS_HIBYTE(SCS_LOWORD(data)),
|
728 |
+
SCS_LOBYTE(SCS_HIWORD(data)),
|
729 |
+
SCS_HIBYTE(SCS_HIWORD(data))
|
730 |
+
];
|
731 |
+
return await this.writeTxRx(port, scsId, address, 4, dataArray);
|
732 |
+
}
|
733 |
+
|
734 |
+
// Add syncReadTx for GroupSyncRead functionality
|
735 |
+
async syncReadTx(port, startAddress, dataLength, param, paramLength) {
|
736 |
+
// Create packet: HEADER0 HEADER1 ID LEN INST START_ADDR DATA_LEN PARAM... CHKSUM
|
737 |
+
const txpacket = new Array(paramLength + 8).fill(0);
|
738 |
+
|
739 |
+
txpacket[PKT_ID] = BROADCAST_ID;
|
740 |
+
txpacket[PKT_LENGTH] = paramLength + 4; // 4: INST START_ADDR DATA_LEN CHKSUM
|
741 |
+
txpacket[PKT_INSTRUCTION] = INST_SYNC_READ;
|
742 |
+
txpacket[PKT_PARAMETER0] = startAddress;
|
743 |
+
txpacket[PKT_PARAMETER0 + 1] = dataLength;
|
744 |
+
|
745 |
+
// Add parameters
|
746 |
+
for (let i = 0; i < paramLength; i++) {
|
747 |
+
txpacket[PKT_PARAMETER0 + 2 + i] = param[i];
|
748 |
+
}
|
749 |
+
|
750 |
+
// Calculate checksum
|
751 |
+
const totalLen = txpacket[PKT_LENGTH] + 4; // 4: HEADER0 HEADER1 ID LENGTH
|
752 |
+
|
753 |
+
// Add headers
|
754 |
+
txpacket[PKT_HEADER0] = 0xff;
|
755 |
+
txpacket[PKT_HEADER1] = 0xff;
|
756 |
+
|
757 |
+
// Calculate checksum
|
758 |
+
let checksum = 0;
|
759 |
+
for (let i = 2; i < totalLen - 1; i++) {
|
760 |
+
checksum += txpacket[i] & 0xff;
|
761 |
+
}
|
762 |
+
txpacket[totalLen - 1] = ~checksum & 0xff;
|
763 |
+
|
764 |
+
console.log(
|
765 |
+
`SyncReadTx: ${txpacket.map((b) => "0x" + b.toString(16).padStart(2, "0")).join(" ")}`
|
766 |
+
);
|
767 |
+
|
768 |
+
// Send packet
|
769 |
+
await port.clearPort();
|
770 |
+
const bytesWritten = await port.writePort(txpacket);
|
771 |
+
if (bytesWritten !== totalLen) {
|
772 |
+
return COMM_TX_FAIL;
|
773 |
+
}
|
774 |
+
|
775 |
+
// Set timeout based on expected response size
|
776 |
+
port.setPacketTimeout((6 + dataLength) * paramLength);
|
777 |
+
|
778 |
+
return COMM_SUCCESS;
|
779 |
+
}
|
780 |
+
|
781 |
+
// Add syncWriteTxOnly for GroupSyncWrite functionality
|
782 |
+
async syncWriteTxOnly(port, startAddress, dataLength, param, paramLength) {
|
783 |
+
// Create packet: HEADER0 HEADER1 ID LEN INST START_ADDR DATA_LEN PARAM... CHKSUM
|
784 |
+
const txpacket = new Array(paramLength + 8).fill(0);
|
785 |
+
|
786 |
+
txpacket[PKT_ID] = BROADCAST_ID;
|
787 |
+
txpacket[PKT_LENGTH] = paramLength + 4; // 4: INST START_ADDR DATA_LEN CHKSUM
|
788 |
+
txpacket[PKT_INSTRUCTION] = INST_SYNC_WRITE;
|
789 |
+
txpacket[PKT_PARAMETER0] = startAddress;
|
790 |
+
txpacket[PKT_PARAMETER0 + 1] = dataLength;
|
791 |
+
|
792 |
+
// Add parameters
|
793 |
+
for (let i = 0; i < paramLength; i++) {
|
794 |
+
txpacket[PKT_PARAMETER0 + 2 + i] = param[i];
|
795 |
+
}
|
796 |
+
|
797 |
+
// Calculate checksum
|
798 |
+
const totalLen = txpacket[PKT_LENGTH] + 4; // 4: HEADER0 HEADER1 ID LENGTH
|
799 |
+
|
800 |
+
// Add headers
|
801 |
+
txpacket[PKT_HEADER0] = 0xff;
|
802 |
+
txpacket[PKT_HEADER1] = 0xff;
|
803 |
+
|
804 |
+
// Calculate checksum
|
805 |
+
let checksum = 0;
|
806 |
+
for (let i = 2; i < totalLen - 1; i++) {
|
807 |
+
checksum += txpacket[i] & 0xff;
|
808 |
+
}
|
809 |
+
txpacket[totalLen - 1] = ~checksum & 0xff;
|
810 |
+
|
811 |
+
console.log(
|
812 |
+
`SyncWriteTxOnly: ${txpacket.map((b) => "0x" + b.toString(16).padStart(2, "0")).join(" ")}`
|
813 |
+
);
|
814 |
+
|
815 |
+
// Send packet - for sync write, we don't need a response
|
816 |
+
await port.clearPort();
|
817 |
+
const bytesWritten = await port.writePort(txpacket);
|
818 |
+
if (bytesWritten !== totalLen) {
|
819 |
+
return COMM_TX_FAIL;
|
820 |
+
}
|
821 |
+
|
822 |
+
return COMM_SUCCESS;
|
823 |
+
}
|
824 |
+
|
825 |
+
// 辅助方法:格式化数据包结构以方便调试
|
826 |
+
formatPacketStructure(packet) {
|
827 |
+
if (!packet || packet.length < 4) {
|
828 |
+
return "Invalid packet (too short)";
|
829 |
+
}
|
830 |
+
|
831 |
+
try {
|
832 |
+
let result = "";
|
833 |
+
result += `HEADER: ${packet[0].toString(16).padStart(2, "0")} ${packet[1].toString(16).padStart(2, "0")} | `;
|
834 |
+
result += `ID: ${packet[2]} | `;
|
835 |
+
result += `LENGTH: ${packet[3]} | `;
|
836 |
+
|
837 |
+
if (packet.length >= 5) {
|
838 |
+
result += `ERROR/INST: ${packet[4].toString(16).padStart(2, "0")} | `;
|
839 |
+
}
|
840 |
+
|
841 |
+
if (packet.length >= 6) {
|
842 |
+
result += "PARAMS: ";
|
843 |
+
for (let i = 5; i < packet.length - 1; i++) {
|
844 |
+
result += `${packet[i].toString(16).padStart(2, "0")} `;
|
845 |
+
}
|
846 |
+
result += `| CHECKSUM: ${packet[packet.length - 1].toString(16).padStart(2, "0")}`;
|
847 |
+
}
|
848 |
+
|
849 |
+
return result;
|
850 |
+
} catch (e) {
|
851 |
+
return "Error formatting packet: " + e.message;
|
852 |
+
}
|
853 |
+
}
|
854 |
+
|
855 |
+
/**
|
856 |
+
* 从响应包中解析舵机型号
|
857 |
+
* @param {Array} rxpacket - 响应数据包
|
858 |
+
* @returns {number} 舵机型号
|
859 |
+
*/
|
860 |
+
parseModelNumber(rxpacket) {
|
861 |
+
if (!rxpacket || rxpacket.length < 7) {
|
862 |
+
return 0;
|
863 |
+
}
|
864 |
+
|
865 |
+
// 检查是否有参数字段
|
866 |
+
if (rxpacket.length <= PKT_PARAMETER0 + 1) {
|
867 |
+
return 0;
|
868 |
+
}
|
869 |
+
|
870 |
+
const param1 = rxpacket[PKT_PARAMETER0];
|
871 |
+
const param2 = rxpacket[PKT_PARAMETER0 + 1];
|
872 |
+
|
873 |
+
if (SCS_END === 0) {
|
874 |
+
// STS/SMS 协议的字节顺序
|
875 |
+
return SCS_MAKEWORD(param1, param2);
|
876 |
+
} else {
|
877 |
+
// SCS 协议的字节顺序
|
878 |
+
return SCS_MAKEWORD(param2, param1);
|
879 |
+
}
|
880 |
+
}
|
881 |
+
|
882 |
+
/**
|
883 |
+
* Verify packet header
|
884 |
+
* @param {Array} packet - The packet to verify
|
885 |
+
* @returns {Number} COMM_SUCCESS if packet is valid, error code otherwise
|
886 |
+
*/
|
887 |
+
getPacketHeader(packet) {
|
888 |
+
if (!packet || packet.length < 4) {
|
889 |
+
return COMM_RX_CORRUPT;
|
890 |
+
}
|
891 |
+
|
892 |
+
// Check header
|
893 |
+
if (packet[PKT_HEADER0] !== 0xff || packet[PKT_HEADER1] !== 0xff) {
|
894 |
+
return COMM_RX_CORRUPT;
|
895 |
+
}
|
896 |
+
|
897 |
+
// Check ID validity
|
898 |
+
if (packet[PKT_ID] > 0xfd) {
|
899 |
+
return COMM_RX_CORRUPT;
|
900 |
+
}
|
901 |
+
|
902 |
+
// Check length
|
903 |
+
if (packet.length != packet[PKT_LENGTH] + 4) {
|
904 |
+
return COMM_RX_CORRUPT;
|
905 |
+
}
|
906 |
+
|
907 |
+
// Calculate checksum
|
908 |
+
let checksum = 0;
|
909 |
+
for (let i = 2; i < packet.length - 1; i++) {
|
910 |
+
checksum += packet[i] & 0xff;
|
911 |
+
}
|
912 |
+
checksum = ~checksum & 0xff;
|
913 |
+
|
914 |
+
// Verify checksum
|
915 |
+
if (packet[packet.length - 1] !== checksum) {
|
916 |
+
return COMM_RX_CORRUPT;
|
917 |
+
}
|
918 |
+
|
919 |
+
return COMM_SUCCESS;
|
920 |
+
}
|
921 |
+
}
|
922 |
+
|
923 |
+
/**
|
924 |
+
* GroupSyncRead class
|
925 |
+
* - This class is used to read multiple servos with the same control table address at once
|
926 |
+
*/
|
927 |
+
export class GroupSyncRead {
|
928 |
+
constructor(port, ph, startAddress, dataLength) {
|
929 |
+
this.port = port;
|
930 |
+
this.ph = ph;
|
931 |
+
this.startAddress = startAddress;
|
932 |
+
this.dataLength = dataLength;
|
933 |
+
|
934 |
+
this.isAvailableServiceID = new Set();
|
935 |
+
this.dataDict = new Map();
|
936 |
+
this.param = [];
|
937 |
+
this.clearParam();
|
938 |
+
}
|
939 |
+
|
940 |
+
makeParam() {
|
941 |
+
this.param = [];
|
942 |
+
for (const id of this.isAvailableServiceID) {
|
943 |
+
this.param.push(id);
|
944 |
+
}
|
945 |
+
return this.param.length;
|
946 |
+
}
|
947 |
+
|
948 |
+
addParam(scsId) {
|
949 |
+
if (this.isAvailableServiceID.has(scsId)) {
|
950 |
+
return false;
|
951 |
+
}
|
952 |
+
|
953 |
+
this.isAvailableServiceID.add(scsId);
|
954 |
+
this.dataDict.set(scsId, new Array(this.dataLength).fill(0));
|
955 |
+
return true;
|
956 |
+
}
|
957 |
+
|
958 |
+
removeParam(scsId) {
|
959 |
+
if (!this.isAvailableServiceID.has(scsId)) {
|
960 |
+
return false;
|
961 |
+
}
|
962 |
+
|
963 |
+
this.isAvailableServiceID.delete(scsId);
|
964 |
+
this.dataDict.delete(scsId);
|
965 |
+
return true;
|
966 |
+
}
|
967 |
+
|
968 |
+
clearParam() {
|
969 |
+
this.isAvailableServiceID.clear();
|
970 |
+
this.dataDict.clear();
|
971 |
+
return true;
|
972 |
+
}
|
973 |
+
|
974 |
+
async txPacket() {
|
975 |
+
if (this.isAvailableServiceID.size === 0) {
|
976 |
+
return COMM_NOT_AVAILABLE;
|
977 |
+
}
|
978 |
+
|
979 |
+
const paramLength = this.makeParam();
|
980 |
+
return await this.ph.syncReadTx(
|
981 |
+
this.port,
|
982 |
+
this.startAddress,
|
983 |
+
this.dataLength,
|
984 |
+
this.param,
|
985 |
+
paramLength
|
986 |
+
);
|
987 |
+
}
|
988 |
+
|
989 |
+
async rxPacket() {
|
990 |
+
let result = COMM_RX_FAIL;
|
991 |
+
|
992 |
+
if (this.isAvailableServiceID.size === 0) {
|
993 |
+
return COMM_NOT_AVAILABLE;
|
994 |
+
}
|
995 |
+
|
996 |
+
// Set all servos' data as invalid
|
997 |
+
for (const id of this.isAvailableServiceID) {
|
998 |
+
this.dataDict.set(id, new Array(this.dataLength).fill(0));
|
999 |
+
}
|
1000 |
+
|
1001 |
+
const [rxpacket, rxResult] = await this.ph.rxPacket(this.port);
|
1002 |
+
if (rxResult !== COMM_SUCCESS || !rxpacket || rxpacket.length === 0) {
|
1003 |
+
return rxResult;
|
1004 |
+
}
|
1005 |
+
|
1006 |
+
// More tolerant of packets with unexpected values in the PKT_ERROR field
|
1007 |
+
// Don't require INST_STATUS to be exactly 0x55
|
1008 |
+
console.log(
|
1009 |
+
`GroupSyncRead rxPacket: ID=${rxpacket[PKT_ID]}, ERROR/INST field=0x${rxpacket[PKT_ERROR].toString(16)}`
|
1010 |
+
);
|
1011 |
+
|
1012 |
+
// Check if the packet matches any of the available IDs
|
1013 |
+
if (!this.isAvailableServiceID.has(rxpacket[PKT_ID])) {
|
1014 |
+
console.log(
|
1015 |
+
`Received packet with ID ${rxpacket[PKT_ID]} which is not in the available IDs list`
|
1016 |
+
);
|
1017 |
+
return COMM_RX_CORRUPT;
|
1018 |
+
}
|
1019 |
+
|
1020 |
+
// Extract data for the matching ID
|
1021 |
+
const scsId = rxpacket[PKT_ID];
|
1022 |
+
const data = new Array(this.dataLength).fill(0);
|
1023 |
+
|
1024 |
+
// Extract the parameter data, which should start at PKT_PARAMETER0
|
1025 |
+
if (rxpacket.length < PKT_PARAMETER0 + this.dataLength) {
|
1026 |
+
console.log(
|
1027 |
+
`Packet too short: expected ${PKT_PARAMETER0 + this.dataLength} bytes, got ${rxpacket.length}`
|
1028 |
+
);
|
1029 |
+
return COMM_RX_CORRUPT;
|
1030 |
+
}
|
1031 |
+
|
1032 |
+
for (let i = 0; i < this.dataLength; i++) {
|
1033 |
+
data[i] = rxpacket[PKT_PARAMETER0 + i];
|
1034 |
+
}
|
1035 |
+
|
1036 |
+
// Update the data dict
|
1037 |
+
this.dataDict.set(scsId, data);
|
1038 |
+
console.log(
|
1039 |
+
`Updated data for servo ID ${scsId}: ${data.map((b) => "0x" + b.toString(16).padStart(2, "0")).join(" ")}`
|
1040 |
+
);
|
1041 |
+
|
1042 |
+
// Continue receiving until timeout or all data is received
|
1043 |
+
if (this.isAvailableServiceID.size > 1) {
|
1044 |
+
result = await this.rxPacket();
|
1045 |
+
} else {
|
1046 |
+
result = COMM_SUCCESS;
|
1047 |
+
}
|
1048 |
+
|
1049 |
+
return result;
|
1050 |
+
}
|
1051 |
+
|
1052 |
+
async txRxPacket() {
|
1053 |
+
try {
|
1054 |
+
// First check if port is being used
|
1055 |
+
if (this.port.isUsing) {
|
1056 |
+
console.log("Port is busy, cannot start sync read operation");
|
1057 |
+
return COMM_PORT_BUSY;
|
1058 |
+
}
|
1059 |
+
|
1060 |
+
// Start the transmission
|
1061 |
+
console.log("Starting sync read TX/RX operation...");
|
1062 |
+
let result = await this.txPacket();
|
1063 |
+
if (result !== COMM_SUCCESS) {
|
1064 |
+
console.log(`Sync read TX failed with result: ${result}`);
|
1065 |
+
return result;
|
1066 |
+
}
|
1067 |
+
|
1068 |
+
// Get a single response with a standard timeout
|
1069 |
+
console.log(`Attempting to receive a response...`);
|
1070 |
+
|
1071 |
+
// Receive a single response
|
1072 |
+
result = await this.rxPacket();
|
1073 |
+
|
1074 |
+
// Release port
|
1075 |
+
this.port.isUsing = false;
|
1076 |
+
|
1077 |
+
return result;
|
1078 |
+
} catch (error) {
|
1079 |
+
console.error("Exception in GroupSyncRead txRxPacket:", error);
|
1080 |
+
// Make sure port is released
|
1081 |
+
this.port.isUsing = false;
|
1082 |
+
return COMM_RX_FAIL;
|
1083 |
+
}
|
1084 |
+
}
|
1085 |
+
|
1086 |
+
isAvailable(scsId, address, dataLength) {
|
1087 |
+
if (!this.isAvailableServiceID.has(scsId)) {
|
1088 |
+
return false;
|
1089 |
+
}
|
1090 |
+
|
1091 |
+
const startAddr = this.startAddress;
|
1092 |
+
const endAddr = startAddr + this.dataLength - 1;
|
1093 |
+
|
1094 |
+
const reqStartAddr = address;
|
1095 |
+
const reqEndAddr = reqStartAddr + dataLength - 1;
|
1096 |
+
|
1097 |
+
if (reqStartAddr < startAddr || reqEndAddr > endAddr) {
|
1098 |
+
return false;
|
1099 |
+
}
|
1100 |
+
|
1101 |
+
const data = this.dataDict.get(scsId);
|
1102 |
+
if (!data || data.length === 0) {
|
1103 |
+
return false;
|
1104 |
+
}
|
1105 |
+
|
1106 |
+
return true;
|
1107 |
+
}
|
1108 |
+
|
1109 |
+
getData(scsId, address, dataLength) {
|
1110 |
+
if (!this.isAvailable(scsId, address, dataLength)) {
|
1111 |
+
return 0;
|
1112 |
+
}
|
1113 |
+
|
1114 |
+
const startAddr = this.startAddress;
|
1115 |
+
const data = this.dataDict.get(scsId);
|
1116 |
+
|
1117 |
+
// Calculate data offset
|
1118 |
+
const dataOffset = address - startAddr;
|
1119 |
+
|
1120 |
+
// Combine bytes according to dataLength
|
1121 |
+
switch (dataLength) {
|
1122 |
+
case 1:
|
1123 |
+
return data[dataOffset];
|
1124 |
+
case 2:
|
1125 |
+
return SCS_MAKEWORD(data[dataOffset], data[dataOffset + 1]);
|
1126 |
+
case 4:
|
1127 |
+
return SCS_MAKEDWORD(
|
1128 |
+
SCS_MAKEWORD(data[dataOffset], data[dataOffset + 1]),
|
1129 |
+
SCS_MAKEWORD(data[dataOffset + 2], data[dataOffset + 3])
|
1130 |
+
);
|
1131 |
+
default:
|
1132 |
+
return 0;
|
1133 |
+
}
|
1134 |
+
}
|
1135 |
+
}
|
1136 |
+
|
1137 |
+
/**
|
1138 |
+
* GroupSyncWrite class
|
1139 |
+
* - This class is used to write multiple servos with the same control table address at once
|
1140 |
+
*/
|
1141 |
+
export class GroupSyncWrite {
|
1142 |
+
constructor(port, ph, startAddress, dataLength) {
|
1143 |
+
this.port = port;
|
1144 |
+
this.ph = ph;
|
1145 |
+
this.startAddress = startAddress;
|
1146 |
+
this.dataLength = dataLength;
|
1147 |
+
|
1148 |
+
this.isAvailableServiceID = new Set();
|
1149 |
+
this.dataDict = new Map();
|
1150 |
+
this.param = [];
|
1151 |
+
this.clearParam();
|
1152 |
+
}
|
1153 |
+
|
1154 |
+
makeParam() {
|
1155 |
+
this.param = [];
|
1156 |
+
for (const id of this.isAvailableServiceID) {
|
1157 |
+
// Add ID to parameter
|
1158 |
+
this.param.push(id);
|
1159 |
+
|
1160 |
+
// Add data to parameter
|
1161 |
+
const data = this.dataDict.get(id);
|
1162 |
+
for (let i = 0; i < this.dataLength; i++) {
|
1163 |
+
this.param.push(data[i]);
|
1164 |
+
}
|
1165 |
+
}
|
1166 |
+
return this.param.length;
|
1167 |
+
}
|
1168 |
+
|
1169 |
+
addParam(scsId, data) {
|
1170 |
+
if (this.isAvailableServiceID.has(scsId)) {
|
1171 |
+
return false;
|
1172 |
+
}
|
1173 |
+
|
1174 |
+
if (data.length !== this.dataLength) {
|
1175 |
+
console.error(
|
1176 |
+
`Data length (${data.length}) doesn't match required length (${this.dataLength})`
|
1177 |
+
);
|
1178 |
+
return false;
|
1179 |
+
}
|
1180 |
+
|
1181 |
+
this.isAvailableServiceID.add(scsId);
|
1182 |
+
this.dataDict.set(scsId, data);
|
1183 |
+
return true;
|
1184 |
+
}
|
1185 |
+
|
1186 |
+
removeParam(scsId) {
|
1187 |
+
if (!this.isAvailableServiceID.has(scsId)) {
|
1188 |
+
return false;
|
1189 |
+
}
|
1190 |
+
|
1191 |
+
this.isAvailableServiceID.delete(scsId);
|
1192 |
+
this.dataDict.delete(scsId);
|
1193 |
+
return true;
|
1194 |
+
}
|
1195 |
+
|
1196 |
+
changeParam(scsId, data) {
|
1197 |
+
if (!this.isAvailableServiceID.has(scsId)) {
|
1198 |
+
return false;
|
1199 |
+
}
|
1200 |
+
|
1201 |
+
if (data.length !== this.dataLength) {
|
1202 |
+
console.error(
|
1203 |
+
`Data length (${data.length}) doesn't match required length (${this.dataLength})`
|
1204 |
+
);
|
1205 |
+
return false;
|
1206 |
+
}
|
1207 |
+
|
1208 |
+
this.dataDict.set(scsId, data);
|
1209 |
+
return true;
|
1210 |
+
}
|
1211 |
+
|
1212 |
+
clearParam() {
|
1213 |
+
this.isAvailableServiceID.clear();
|
1214 |
+
this.dataDict.clear();
|
1215 |
+
return true;
|
1216 |
+
}
|
1217 |
+
|
1218 |
+
async txPacket() {
|
1219 |
+
if (this.isAvailableServiceID.size === 0) {
|
1220 |
+
return COMM_NOT_AVAILABLE;
|
1221 |
+
}
|
1222 |
+
|
1223 |
+
const paramLength = this.makeParam();
|
1224 |
+
return await this.ph.syncWriteTxOnly(
|
1225 |
+
this.port,
|
1226 |
+
this.startAddress,
|
1227 |
+
this.dataLength,
|
1228 |
+
this.param,
|
1229 |
+
paramLength
|
1230 |
+
);
|
1231 |
+
}
|
1232 |
+
}
|
packages/feetech.js/package.json
ADDED
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"name": "feetech.js",
|
3 |
+
"version": "0.0.8",
|
4 |
+
"description": "javascript sdk for feetech servos",
|
5 |
+
"main": "index.mjs",
|
6 |
+
"files": [
|
7 |
+
"*.mjs",
|
8 |
+
"*.ts"
|
9 |
+
],
|
10 |
+
"type": "module",
|
11 |
+
"engines": {
|
12 |
+
"node": ">=12.17.0"
|
13 |
+
},
|
14 |
+
"scripts": {
|
15 |
+
"test": "echo \"Error: no test specified\" && exit 1"
|
16 |
+
},
|
17 |
+
"repository": {
|
18 |
+
"type": "git",
|
19 |
+
"url": "git+https://github.com/timqian/bambot/tree/main/feetech.js"
|
20 |
+
},
|
21 |
+
"keywords": [
|
22 |
+
"feetech",
|
23 |
+
"sdk",
|
24 |
+
"js",
|
25 |
+
"javascript",
|
26 |
+
"sts3215",
|
27 |
+
"3215",
|
28 |
+
"scs",
|
29 |
+
"scs3215",
|
30 |
+
"st3215"
|
31 |
+
],
|
32 |
+
"author": "timqian",
|
33 |
+
"license": "MIT",
|
34 |
+
"bugs": {
|
35 |
+
"url": "https://github.com/timqian/bambot/issues"
|
36 |
+
},
|
37 |
+
"homepage": "https://github.com/timqian/bambot/tree/main/feetech.js"
|
38 |
+
}
|
packages/feetech.js/scsServoSDK.mjs
ADDED
@@ -0,0 +1,910 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import {
|
2 |
+
PortHandler,
|
3 |
+
PacketHandler,
|
4 |
+
COMM_SUCCESS,
|
5 |
+
COMM_RX_TIMEOUT,
|
6 |
+
COMM_RX_CORRUPT,
|
7 |
+
COMM_TX_FAIL,
|
8 |
+
COMM_NOT_AVAILABLE,
|
9 |
+
SCS_LOBYTE,
|
10 |
+
SCS_HIBYTE,
|
11 |
+
SCS_MAKEWORD,
|
12 |
+
GroupSyncRead, // Import GroupSyncRead
|
13 |
+
GroupSyncWrite // Import GroupSyncWrite
|
14 |
+
} from "./lowLevelSDK.mjs";
|
15 |
+
|
16 |
+
// Import address constants from the correct file
|
17 |
+
import {
|
18 |
+
ADDR_SCS_PRESENT_POSITION,
|
19 |
+
ADDR_SCS_GOAL_POSITION,
|
20 |
+
ADDR_SCS_TORQUE_ENABLE,
|
21 |
+
ADDR_SCS_GOAL_ACC,
|
22 |
+
ADDR_SCS_GOAL_SPEED
|
23 |
+
} from "./scsservo_constants.mjs";
|
24 |
+
|
25 |
+
// Define constants not present in scsservo_constants.mjs
|
26 |
+
const ADDR_SCS_MODE = 33;
|
27 |
+
const ADDR_SCS_LOCK = 55;
|
28 |
+
const ADDR_SCS_ID = 5; // Address for Servo ID
|
29 |
+
const ADDR_SCS_BAUD_RATE = 6; // Address for Baud Rate
|
30 |
+
|
31 |
+
// Module-level variables for handlers
|
32 |
+
let portHandler = null;
|
33 |
+
let packetHandler = null;
|
34 |
+
|
35 |
+
/**
|
36 |
+
* Connects to the serial port and initializes handlers.
|
37 |
+
* @param {object} [options] - Connection options.
|
38 |
+
* @param {number} [options.baudRate=1000000] - The baud rate for the serial connection.
|
39 |
+
* @param {number} [options.protocolEnd=0] - The protocol end setting (0 for STS/SMS, 1 for SCS).
|
40 |
+
* @returns {Promise<true>} Resolves with true on successful connection.
|
41 |
+
* @throws {Error} If connection fails or port cannot be opened/selected.
|
42 |
+
*/
|
43 |
+
export async function connect(options = {}) {
|
44 |
+
if (portHandler && portHandler.isOpen) {
|
45 |
+
console.log("Already connected.");
|
46 |
+
return true;
|
47 |
+
}
|
48 |
+
|
49 |
+
const { baudRate = 1000000, protocolEnd = 0 } = options;
|
50 |
+
|
51 |
+
try {
|
52 |
+
portHandler = new PortHandler();
|
53 |
+
const portRequested = await portHandler.requestPort();
|
54 |
+
if (!portRequested) {
|
55 |
+
portHandler = null;
|
56 |
+
throw new Error("Failed to select a serial port.");
|
57 |
+
}
|
58 |
+
|
59 |
+
portHandler.setBaudRate(baudRate);
|
60 |
+
const portOpened = await portHandler.openPort();
|
61 |
+
if (!portOpened) {
|
62 |
+
await portHandler.closePort().catch(console.error); // Attempt cleanup
|
63 |
+
portHandler = null;
|
64 |
+
throw new Error(`Failed to open port at baudrate ${baudRate}.`);
|
65 |
+
}
|
66 |
+
|
67 |
+
packetHandler = new PacketHandler(protocolEnd);
|
68 |
+
console.log(`Connected to serial port at ${baudRate} baud, protocol end: ${protocolEnd}.`);
|
69 |
+
return true;
|
70 |
+
} catch (err) {
|
71 |
+
console.error("Error during connection:", err);
|
72 |
+
if (portHandler) {
|
73 |
+
try {
|
74 |
+
await portHandler.closePort();
|
75 |
+
} catch (closeErr) {
|
76 |
+
console.error("Error closing port after connection failure:", closeErr);
|
77 |
+
}
|
78 |
+
}
|
79 |
+
portHandler = null;
|
80 |
+
packetHandler = null;
|
81 |
+
// Re-throw the original or a new error
|
82 |
+
throw new Error(`Connection failed: ${err.message}`);
|
83 |
+
}
|
84 |
+
}
|
85 |
+
|
86 |
+
/**
|
87 |
+
* Disconnects from the serial port.
|
88 |
+
* @returns {Promise<true>} Resolves with true on successful disconnection.
|
89 |
+
* @throws {Error} If disconnection fails.
|
90 |
+
*/
|
91 |
+
export async function disconnect() {
|
92 |
+
if (!portHandler || !portHandler.isOpen) {
|
93 |
+
console.log("Already disconnected.");
|
94 |
+
return true;
|
95 |
+
}
|
96 |
+
|
97 |
+
try {
|
98 |
+
await portHandler.closePort();
|
99 |
+
portHandler = null;
|
100 |
+
packetHandler = null;
|
101 |
+
console.log("Disconnected from serial port.");
|
102 |
+
return true;
|
103 |
+
} catch (err) {
|
104 |
+
console.error("Error during disconnection:", err);
|
105 |
+
// Attempt to nullify handlers even if close fails
|
106 |
+
portHandler = null;
|
107 |
+
packetHandler = null;
|
108 |
+
throw new Error(`Disconnection failed: ${err.message}`);
|
109 |
+
}
|
110 |
+
}
|
111 |
+
|
112 |
+
/**
|
113 |
+
* Checks if the SDK is connected. Throws an error if not.
|
114 |
+
* @throws {Error} If not connected.
|
115 |
+
*/
|
116 |
+
function checkConnection() {
|
117 |
+
if (!portHandler || !packetHandler) {
|
118 |
+
throw new Error("Not connected. Call connect() first.");
|
119 |
+
}
|
120 |
+
}
|
121 |
+
|
122 |
+
/**
|
123 |
+
* Reads the current position of a servo.
|
124 |
+
* @param {number} servoId - The ID of the servo (1-252).
|
125 |
+
* @returns {Promise<number>} Resolves with the position (0-4095).
|
126 |
+
* @throws {Error} If not connected, read fails, or an exception occurs.
|
127 |
+
*/
|
128 |
+
export async function readPosition(servoId) {
|
129 |
+
checkConnection();
|
130 |
+
try {
|
131 |
+
const [position, result, error] = await packetHandler.read2ByteTxRx(
|
132 |
+
portHandler,
|
133 |
+
servoId,
|
134 |
+
ADDR_SCS_PRESENT_POSITION
|
135 |
+
);
|
136 |
+
|
137 |
+
if (result !== COMM_SUCCESS) {
|
138 |
+
throw new Error(
|
139 |
+
`Error reading position from servo ${servoId}: ${packetHandler.getTxRxResult(
|
140 |
+
result
|
141 |
+
)}, Error code: ${error}`
|
142 |
+
);
|
143 |
+
}
|
144 |
+
return position & 0xffff; // Ensure it's within 16 bits
|
145 |
+
} catch (err) {
|
146 |
+
console.error(`Exception reading position from servo ${servoId}:`, err);
|
147 |
+
throw new Error(`Exception reading position from servo ${servoId}: ${err.message}`);
|
148 |
+
}
|
149 |
+
}
|
150 |
+
|
151 |
+
/**
|
152 |
+
* Reads the current baud rate index of a servo.
|
153 |
+
* @param {number} servoId - The ID of the servo (1-252).
|
154 |
+
* @returns {Promise<number>} Resolves with the baud rate index (0-7).
|
155 |
+
* @throws {Error} If not connected, read fails, or an exception occurs.
|
156 |
+
*/
|
157 |
+
export async function readBaudRate(servoId) {
|
158 |
+
checkConnection();
|
159 |
+
try {
|
160 |
+
const [baudIndex, result, error] = await packetHandler.read1ByteTxRx(
|
161 |
+
portHandler,
|
162 |
+
servoId,
|
163 |
+
ADDR_SCS_BAUD_RATE
|
164 |
+
);
|
165 |
+
|
166 |
+
if (result !== COMM_SUCCESS) {
|
167 |
+
throw new Error(
|
168 |
+
`Error reading baud rate from servo ${servoId}: ${packetHandler.getTxRxResult(
|
169 |
+
result
|
170 |
+
)}, Error code: ${error}`
|
171 |
+
);
|
172 |
+
}
|
173 |
+
return baudIndex;
|
174 |
+
} catch (err) {
|
175 |
+
console.error(`Exception reading baud rate from servo ${servoId}:`, err);
|
176 |
+
throw new Error(`Exception reading baud rate from servo ${servoId}: ${err.message}`);
|
177 |
+
}
|
178 |
+
}
|
179 |
+
|
180 |
+
/**
|
181 |
+
* Reads the current operating mode of a servo.
|
182 |
+
* @param {number} servoId - The ID of the servo (1-252).
|
183 |
+
* @returns {Promise<number>} Resolves with the mode (0 for position, 1 for wheel).
|
184 |
+
* @throws {Error} If not connected, read fails, or an exception occurs.
|
185 |
+
*/
|
186 |
+
export async function readMode(servoId) {
|
187 |
+
checkConnection();
|
188 |
+
try {
|
189 |
+
const [modeValue, result, error] = await packetHandler.read1ByteTxRx(
|
190 |
+
portHandler,
|
191 |
+
servoId,
|
192 |
+
ADDR_SCS_MODE
|
193 |
+
);
|
194 |
+
|
195 |
+
if (result !== COMM_SUCCESS) {
|
196 |
+
throw new Error(
|
197 |
+
`Error reading mode from servo ${servoId}: ${packetHandler.getTxRxResult(
|
198 |
+
result
|
199 |
+
)}, Error code: ${error}`
|
200 |
+
);
|
201 |
+
}
|
202 |
+
return modeValue;
|
203 |
+
} catch (err) {
|
204 |
+
console.error(`Exception reading mode from servo ${servoId}:`, err);
|
205 |
+
throw new Error(`Exception reading mode from servo ${servoId}: ${err.message}`);
|
206 |
+
}
|
207 |
+
}
|
208 |
+
|
209 |
+
/**
|
210 |
+
* Writes a target position to a servo.
|
211 |
+
* @param {number} servoId - The ID of the servo (1-252).
|
212 |
+
* @param {number} position - The target position value (0-4095).
|
213 |
+
* @returns {Promise<"success">} Resolves with "success".
|
214 |
+
* @throws {Error} If not connected, position is out of range, write fails, or an exception occurs.
|
215 |
+
*/
|
216 |
+
export async function writePosition(servoId, position) {
|
217 |
+
checkConnection();
|
218 |
+
try {
|
219 |
+
// Validate position range
|
220 |
+
if (position < 0 || position > 4095) {
|
221 |
+
throw new Error(
|
222 |
+
`Invalid position value ${position} for servo ${servoId}. Must be between 0 and 4095.`
|
223 |
+
);
|
224 |
+
}
|
225 |
+
const targetPosition = Math.round(position); // Ensure integer value
|
226 |
+
|
227 |
+
const [result, error] = await packetHandler.write2ByteTxRx(
|
228 |
+
portHandler,
|
229 |
+
servoId,
|
230 |
+
ADDR_SCS_GOAL_POSITION,
|
231 |
+
targetPosition
|
232 |
+
);
|
233 |
+
|
234 |
+
if (result !== COMM_SUCCESS) {
|
235 |
+
throw new Error(
|
236 |
+
`Error writing position to servo ${servoId}: ${packetHandler.getTxRxResult(
|
237 |
+
result
|
238 |
+
)}, Error code: ${error}`
|
239 |
+
);
|
240 |
+
}
|
241 |
+
return "success";
|
242 |
+
} catch (err) {
|
243 |
+
console.error(`Exception writing position to servo ${servoId}:`, err);
|
244 |
+
// Re-throw the original error or a new one wrapping it
|
245 |
+
throw new Error(`Failed to write position to servo ${servoId}: ${err.message}`);
|
246 |
+
}
|
247 |
+
}
|
248 |
+
|
249 |
+
/**
|
250 |
+
* Enables or disables the torque of a servo.
|
251 |
+
* @param {number} servoId - The ID of the servo (1-252).
|
252 |
+
* @param {boolean} enable - True to enable torque, false to disable.
|
253 |
+
* @returns {Promise<"success">} Resolves with "success".
|
254 |
+
* @throws {Error} If not connected, write fails, or an exception occurs.
|
255 |
+
*/
|
256 |
+
export async function writeTorqueEnable(servoId, enable) {
|
257 |
+
checkConnection();
|
258 |
+
try {
|
259 |
+
const enableValue = enable ? 1 : 0;
|
260 |
+
const [result, error] = await packetHandler.write1ByteTxRx(
|
261 |
+
portHandler,
|
262 |
+
servoId,
|
263 |
+
ADDR_SCS_TORQUE_ENABLE,
|
264 |
+
enableValue
|
265 |
+
);
|
266 |
+
|
267 |
+
if (result !== COMM_SUCCESS) {
|
268 |
+
throw new Error(
|
269 |
+
`Error setting torque for servo ${servoId}: ${packetHandler.getTxRxResult(
|
270 |
+
result
|
271 |
+
)}, Error code: ${error}`
|
272 |
+
);
|
273 |
+
}
|
274 |
+
return "success";
|
275 |
+
} catch (err) {
|
276 |
+
console.error(`Exception setting torque for servo ${servoId}:`, err);
|
277 |
+
throw new Error(`Exception setting torque for servo ${servoId}: ${err.message}`);
|
278 |
+
}
|
279 |
+
}
|
280 |
+
|
281 |
+
/**
|
282 |
+
* Sets the acceleration profile for a servo's movement.
|
283 |
+
* @param {number} servoId - The ID of the servo (1-252).
|
284 |
+
* @param {number} acceleration - The acceleration value (0-254).
|
285 |
+
* @returns {Promise<"success">} Resolves with "success".
|
286 |
+
* @throws {Error} If not connected, write fails, or an exception occurs.
|
287 |
+
*/
|
288 |
+
export async function writeAcceleration(servoId, acceleration) {
|
289 |
+
checkConnection();
|
290 |
+
try {
|
291 |
+
const clampedAcceleration = Math.max(0, Math.min(254, Math.round(acceleration)));
|
292 |
+
const [result, error] = await packetHandler.write1ByteTxRx(
|
293 |
+
portHandler,
|
294 |
+
servoId,
|
295 |
+
ADDR_SCS_GOAL_ACC,
|
296 |
+
clampedAcceleration
|
297 |
+
);
|
298 |
+
|
299 |
+
if (result !== COMM_SUCCESS) {
|
300 |
+
throw new Error(
|
301 |
+
`Error writing acceleration to servo ${servoId}: ${packetHandler.getTxRxResult(
|
302 |
+
result
|
303 |
+
)}, Error code: ${error}`
|
304 |
+
);
|
305 |
+
}
|
306 |
+
return "success";
|
307 |
+
} catch (err) {
|
308 |
+
console.error(`Exception writing acceleration to servo ${servoId}:`, err);
|
309 |
+
throw new Error(`Exception writing acceleration to servo ${servoId}: ${err.message}`);
|
310 |
+
}
|
311 |
+
}
|
312 |
+
|
313 |
+
/**
|
314 |
+
* Helper to attempt locking a servo, logging errors without throwing.
|
315 |
+
* @param {number} servoId
|
316 |
+
*/
|
317 |
+
async function tryLockServo(servoId) {
|
318 |
+
try {
|
319 |
+
await packetHandler.write1ByteTxRx(portHandler, servoId, ADDR_SCS_LOCK, 1);
|
320 |
+
} catch (lockErr) {
|
321 |
+
console.error(`Failed to re-lock servo ${servoId}:`, lockErr);
|
322 |
+
}
|
323 |
+
}
|
324 |
+
|
325 |
+
/**
|
326 |
+
* Sets a servo to wheel mode (continuous rotation).
|
327 |
+
* Requires unlocking, setting mode, and locking the configuration.
|
328 |
+
* @param {number} servoId - The ID of the servo (1-252).
|
329 |
+
* @returns {Promise<"success">} Resolves with "success".
|
330 |
+
* @throws {Error} If not connected, any step fails, or an exception occurs.
|
331 |
+
*/
|
332 |
+
export async function setWheelMode(servoId) {
|
333 |
+
checkConnection();
|
334 |
+
let unlocked = false;
|
335 |
+
try {
|
336 |
+
console.log(`Setting servo ${servoId} to wheel mode...`);
|
337 |
+
|
338 |
+
// 1. Unlock servo configuration
|
339 |
+
const [resUnlock, errUnlock] = await packetHandler.write1ByteTxRx(
|
340 |
+
portHandler,
|
341 |
+
servoId,
|
342 |
+
ADDR_SCS_LOCK,
|
343 |
+
0
|
344 |
+
);
|
345 |
+
if (resUnlock !== COMM_SUCCESS) {
|
346 |
+
throw new Error(
|
347 |
+
`Failed to unlock servo ${servoId}: ${packetHandler.getTxRxResult(
|
348 |
+
resUnlock
|
349 |
+
)}, Error: ${errUnlock}`
|
350 |
+
);
|
351 |
+
}
|
352 |
+
unlocked = true;
|
353 |
+
|
354 |
+
// 2. Set mode to 1 (Wheel/Speed mode)
|
355 |
+
const [resMode, errMode] = await packetHandler.write1ByteTxRx(
|
356 |
+
portHandler,
|
357 |
+
servoId,
|
358 |
+
ADDR_SCS_MODE,
|
359 |
+
1
|
360 |
+
);
|
361 |
+
if (resMode !== COMM_SUCCESS) {
|
362 |
+
throw new Error(
|
363 |
+
`Failed to set wheel mode for servo ${servoId}: ${packetHandler.getTxRxResult(
|
364 |
+
resMode
|
365 |
+
)}, Error: ${errMode}`
|
366 |
+
);
|
367 |
+
}
|
368 |
+
|
369 |
+
// 3. Lock servo configuration
|
370 |
+
const [resLock, errLock] = await packetHandler.write1ByteTxRx(
|
371 |
+
portHandler,
|
372 |
+
servoId,
|
373 |
+
ADDR_SCS_LOCK,
|
374 |
+
1
|
375 |
+
);
|
376 |
+
if (resLock !== COMM_SUCCESS) {
|
377 |
+
// Mode was set, but lock failed. Still an error state.
|
378 |
+
throw new Error(
|
379 |
+
`Failed to lock servo ${servoId} after setting mode: ${packetHandler.getTxRxResult(
|
380 |
+
resLock
|
381 |
+
)}, Error: ${errLock}`
|
382 |
+
);
|
383 |
+
}
|
384 |
+
unlocked = false; // Successfully locked
|
385 |
+
|
386 |
+
console.log(`Successfully set servo ${servoId} to wheel mode.`);
|
387 |
+
return "success";
|
388 |
+
} catch (err) {
|
389 |
+
console.error(`Exception setting wheel mode for servo ${servoId}:`, err);
|
390 |
+
if (unlocked) {
|
391 |
+
// Attempt to re-lock if an error occurred after unlocking
|
392 |
+
await tryLockServo(servoId);
|
393 |
+
}
|
394 |
+
// Re-throw the original error or a new one wrapping it
|
395 |
+
throw new Error(`Failed to set wheel mode for servo ${servoId}: ${err.message}`);
|
396 |
+
}
|
397 |
+
}
|
398 |
+
|
399 |
+
/**
|
400 |
+
* Sets a servo back to position control mode from wheel mode.
|
401 |
+
* Requires unlocking, setting mode, and locking the configuration.
|
402 |
+
* @param {number} servoId - The ID of the servo (1-252).
|
403 |
+
* @returns {Promise<"success">} Resolves with "success".
|
404 |
+
* @throws {Error} If not connected, any step fails, or an exception occurs.
|
405 |
+
*/
|
406 |
+
export async function setPositionMode(servoId) {
|
407 |
+
checkConnection();
|
408 |
+
let unlocked = false;
|
409 |
+
try {
|
410 |
+
console.log(`Setting servo ${servoId} back to position mode...`);
|
411 |
+
|
412 |
+
// 1. Unlock servo configuration
|
413 |
+
const [resUnlock, errUnlock] = await packetHandler.write1ByteTxRx(
|
414 |
+
portHandler,
|
415 |
+
servoId,
|
416 |
+
ADDR_SCS_LOCK,
|
417 |
+
0
|
418 |
+
);
|
419 |
+
if (resUnlock !== COMM_SUCCESS) {
|
420 |
+
throw new Error(
|
421 |
+
`Failed to unlock servo ${servoId}: ${packetHandler.getTxRxResult(
|
422 |
+
resUnlock
|
423 |
+
)}, Error: ${errUnlock}`
|
424 |
+
);
|
425 |
+
}
|
426 |
+
unlocked = true;
|
427 |
+
|
428 |
+
// 2. Set mode to 0 (Position/Servo mode)
|
429 |
+
const [resMode, errMode] = await packetHandler.write1ByteTxRx(
|
430 |
+
portHandler,
|
431 |
+
servoId,
|
432 |
+
ADDR_SCS_MODE,
|
433 |
+
0 // 0 for position mode
|
434 |
+
);
|
435 |
+
if (resMode !== COMM_SUCCESS) {
|
436 |
+
throw new Error(
|
437 |
+
`Failed to set position mode for servo ${servoId}: ${packetHandler.getTxRxResult(
|
438 |
+
resMode
|
439 |
+
)}, Error: ${errMode}`
|
440 |
+
);
|
441 |
+
}
|
442 |
+
|
443 |
+
// 3. Lock servo configuration
|
444 |
+
const [resLock, errLock] = await packetHandler.write1ByteTxRx(
|
445 |
+
portHandler,
|
446 |
+
servoId,
|
447 |
+
ADDR_SCS_LOCK,
|
448 |
+
1
|
449 |
+
);
|
450 |
+
if (resLock !== COMM_SUCCESS) {
|
451 |
+
throw new Error(
|
452 |
+
`Failed to lock servo ${servoId} after setting mode: ${packetHandler.getTxRxResult(
|
453 |
+
resLock
|
454 |
+
)}, Error: ${errLock}`
|
455 |
+
);
|
456 |
+
}
|
457 |
+
unlocked = false; // Successfully locked
|
458 |
+
|
459 |
+
console.log(`Successfully set servo ${servoId} back to position mode.`);
|
460 |
+
return "success";
|
461 |
+
} catch (err) {
|
462 |
+
console.error(`Exception setting position mode for servo ${servoId}:`, err);
|
463 |
+
if (unlocked) {
|
464 |
+
// Attempt to re-lock if an error occurred after unlocking
|
465 |
+
await tryLockServo(servoId);
|
466 |
+
}
|
467 |
+
throw new Error(`Failed to set position mode for servo ${servoId}: ${err.message}`);
|
468 |
+
}
|
469 |
+
}
|
470 |
+
|
471 |
+
/**
|
472 |
+
* Writes a target speed for a servo in wheel mode.
|
473 |
+
* @param {number} servoId - The ID of the servo
|
474 |
+
* @param {number} speed - The target speed value (-10000 to 10000). Negative values indicate reverse direction. 0 stops the wheel.
|
475 |
+
* @returns {Promise<"success">} Resolves with "success".
|
476 |
+
* @throws {Error} If not connected, either write fails, or an exception occurs.
|
477 |
+
*/
|
478 |
+
export async function writeWheelSpeed(servoId, speed) {
|
479 |
+
checkConnection();
|
480 |
+
try {
|
481 |
+
// Validate and clamp the speed to the new range
|
482 |
+
const clampedSpeed = Math.max(-10000, Math.min(10000, Math.round(speed)));
|
483 |
+
let speedValue = Math.abs(clampedSpeed) & 0x7fff; // Get absolute value, ensure within 15 bits
|
484 |
+
|
485 |
+
// Set the direction bit (MSB of the 16-bit value) if speed is negative
|
486 |
+
if (clampedSpeed < 0) {
|
487 |
+
speedValue |= 0x8000; // Set the 16th bit for reverse direction
|
488 |
+
}
|
489 |
+
|
490 |
+
// Use write2ByteTxRx to write the 16-bit speed value
|
491 |
+
const [result, error] = await packetHandler.write2ByteTxRx(
|
492 |
+
portHandler,
|
493 |
+
servoId,
|
494 |
+
ADDR_SCS_GOAL_SPEED, // Starting address for the 2-byte speed value
|
495 |
+
speedValue
|
496 |
+
);
|
497 |
+
|
498 |
+
if (result !== COMM_SUCCESS) {
|
499 |
+
throw new Error(
|
500 |
+
`Error writing wheel speed to servo ${servoId}: ${packetHandler.getTxRxResult(
|
501 |
+
result
|
502 |
+
)}, Error: ${error}`
|
503 |
+
);
|
504 |
+
}
|
505 |
+
|
506 |
+
return "success";
|
507 |
+
} catch (err) {
|
508 |
+
console.error(`Exception writing wheel speed to servo ${servoId}:`, err);
|
509 |
+
throw new Error(`Exception writing wheel speed to servo ${servoId}: ${err.message}`);
|
510 |
+
}
|
511 |
+
}
|
512 |
+
|
513 |
+
/**
|
514 |
+
* Writes target speeds to multiple servos in wheel mode synchronously.
|
515 |
+
* @param {Map<number, number> | object} servoSpeeds - A Map or object where keys are servo IDs (1-252) and values are target speeds (-10000 to 10000).
|
516 |
+
* @returns {Promise<"success">} Resolves with "success".
|
517 |
+
* @throws {Error} If not connected, any speed is out of range, transmission fails, or an exception occurs.
|
518 |
+
*/
|
519 |
+
export async function syncWriteWheelSpeed(servoSpeeds) {
|
520 |
+
checkConnection();
|
521 |
+
|
522 |
+
const groupSyncWrite = new GroupSyncWrite(
|
523 |
+
portHandler,
|
524 |
+
packetHandler,
|
525 |
+
ADDR_SCS_GOAL_SPEED,
|
526 |
+
2 // Data length for speed (2 bytes)
|
527 |
+
);
|
528 |
+
let paramAdded = false;
|
529 |
+
|
530 |
+
const entries = servoSpeeds instanceof Map ? servoSpeeds.entries() : Object.entries(servoSpeeds);
|
531 |
+
|
532 |
+
// Second pass: Add valid parameters
|
533 |
+
for (const [idStr, speed] of entries) {
|
534 |
+
const servoId = parseInt(idStr, 10); // Already validated
|
535 |
+
|
536 |
+
if (isNaN(servoId) || servoId < 1 || servoId > 252) {
|
537 |
+
throw new Error(`Invalid servo ID "${idStr}" in syncWriteWheelSpeed.`);
|
538 |
+
}
|
539 |
+
if (speed < -10000 || speed > 10000) {
|
540 |
+
throw new Error(
|
541 |
+
`Invalid speed value ${speed} for servo ${servoId} in syncWriteWheelSpeed. Must be between -10000 and 10000.`
|
542 |
+
);
|
543 |
+
}
|
544 |
+
|
545 |
+
const clampedSpeed = Math.max(-10000, Math.min(10000, Math.round(speed))); // Ensure integer, already validated range
|
546 |
+
let speedValue = Math.abs(clampedSpeed) & 0x7fff; // Get absolute value, ensure within 15 bits
|
547 |
+
|
548 |
+
// Set the direction bit (MSB of the 16-bit value) if speed is negative
|
549 |
+
if (clampedSpeed < 0) {
|
550 |
+
speedValue |= 0x8000; // Set the 16th bit for reverse direction
|
551 |
+
}
|
552 |
+
|
553 |
+
const data = [SCS_LOBYTE(speedValue), SCS_HIBYTE(speedValue)];
|
554 |
+
|
555 |
+
if (groupSyncWrite.addParam(servoId, data)) {
|
556 |
+
paramAdded = true;
|
557 |
+
} else {
|
558 |
+
// This should ideally not happen if IDs are unique, but handle defensively
|
559 |
+
console.warn(
|
560 |
+
`Failed to add servo ${servoId} to sync write speed group (possibly duplicate).`
|
561 |
+
);
|
562 |
+
}
|
563 |
+
}
|
564 |
+
|
565 |
+
if (!paramAdded) {
|
566 |
+
console.log("Sync Write Speed: No valid servo speeds provided or added.");
|
567 |
+
return "success"; // Nothing to write is considered success
|
568 |
+
}
|
569 |
+
|
570 |
+
try {
|
571 |
+
// Send the Sync Write instruction
|
572 |
+
const result = await groupSyncWrite.txPacket();
|
573 |
+
if (result !== COMM_SUCCESS) {
|
574 |
+
throw new Error(`Sync Write Speed txPacket failed: ${packetHandler.getTxRxResult(result)}`);
|
575 |
+
}
|
576 |
+
return "success";
|
577 |
+
} catch (err) {
|
578 |
+
console.error("Exception during syncWriteWheelSpeed:", err);
|
579 |
+
// Re-throw the original error or a new one wrapping it
|
580 |
+
throw new Error(`Sync Write Speed failed: ${err.message}`);
|
581 |
+
}
|
582 |
+
}
|
583 |
+
|
584 |
+
/**
|
585 |
+
* Reads the current position of multiple servos synchronously.
|
586 |
+
* @param {number[]} servoIds - An array of servo IDs (1-252) to read from.
|
587 |
+
* @returns {Promise<Map<number, number>>} Resolves with a Map where keys are servo IDs and values are positions (0-4095).
|
588 |
+
* @throws {Error} If not connected, transmission fails, reception fails, or data for any requested servo is unavailable.
|
589 |
+
*/
|
590 |
+
export async function syncReadPositions(servoIds) {
|
591 |
+
checkConnection();
|
592 |
+
if (!Array.isArray(servoIds) || servoIds.length === 0) {
|
593 |
+
console.log("Sync Read: No servo IDs provided.");
|
594 |
+
return new Map(); // Return empty map for empty input
|
595 |
+
}
|
596 |
+
|
597 |
+
const startAddress = ADDR_SCS_PRESENT_POSITION;
|
598 |
+
const dataLength = 2;
|
599 |
+
const groupSyncRead = new GroupSyncRead(portHandler, packetHandler, startAddress, dataLength);
|
600 |
+
const positions = new Map();
|
601 |
+
const validIds = [];
|
602 |
+
|
603 |
+
// 1. Add parameters for each valid servo ID
|
604 |
+
servoIds.forEach((id) => {
|
605 |
+
if (id >= 1 && id <= 252) {
|
606 |
+
if (groupSyncRead.addParam(id)) {
|
607 |
+
validIds.push(id);
|
608 |
+
} else {
|
609 |
+
console.warn(
|
610 |
+
`Sync Read: Failed to add param for servo ID ${id} (maybe duplicate or invalid).`
|
611 |
+
);
|
612 |
+
}
|
613 |
+
} else {
|
614 |
+
console.warn(`Sync Read: Invalid servo ID ${id} skipped.`);
|
615 |
+
}
|
616 |
+
});
|
617 |
+
|
618 |
+
if (validIds.length === 0) {
|
619 |
+
console.log("Sync Read: No valid servo IDs to read.");
|
620 |
+
return new Map(); // Return empty map if no valid IDs
|
621 |
+
}
|
622 |
+
|
623 |
+
try {
|
624 |
+
// 2. Send the Sync Read instruction packet
|
625 |
+
let txResult = await groupSyncRead.txPacket();
|
626 |
+
if (txResult !== COMM_SUCCESS) {
|
627 |
+
throw new Error(`Sync Read txPacket failed: ${packetHandler.getTxRxResult(txResult)}`);
|
628 |
+
}
|
629 |
+
|
630 |
+
// 3. Receive the response packets
|
631 |
+
let rxResult = await groupSyncRead.rxPacket();
|
632 |
+
// Even if rxPacket reports an overall issue (like timeout), we still check individual servos.
|
633 |
+
// A specific error will be thrown later if any servo data is missing.
|
634 |
+
if (rxResult !== COMM_SUCCESS) {
|
635 |
+
console.warn(
|
636 |
+
`Sync Read rxPacket overall result: ${packetHandler.getTxRxResult(
|
637 |
+
rxResult
|
638 |
+
)}. Checking individual servos.`
|
639 |
+
);
|
640 |
+
}
|
641 |
+
|
642 |
+
// 4. Check data availability and retrieve data for each servo
|
643 |
+
const failedIds = [];
|
644 |
+
validIds.forEach((id) => {
|
645 |
+
const isAvailable = groupSyncRead.isAvailable(id, startAddress, dataLength);
|
646 |
+
if (isAvailable) {
|
647 |
+
const position = groupSyncRead.getData(id, startAddress, dataLength);
|
648 |
+
positions.set(id, position & 0xffff);
|
649 |
+
} else {
|
650 |
+
failedIds.push(id);
|
651 |
+
}
|
652 |
+
});
|
653 |
+
|
654 |
+
// 5. Check if all requested servos responded
|
655 |
+
if (failedIds.length > 0) {
|
656 |
+
throw new Error(
|
657 |
+
`Sync Read failed: Data not available for servo IDs: ${failedIds.join(
|
658 |
+
", "
|
659 |
+
)}. Overall RX result: ${packetHandler.getTxRxResult(rxResult)}`
|
660 |
+
);
|
661 |
+
}
|
662 |
+
|
663 |
+
return positions;
|
664 |
+
} catch (err) {
|
665 |
+
console.error("Exception or failure during syncReadPositions:", err);
|
666 |
+
// Re-throw the caught error or a new one wrapping it
|
667 |
+
throw new Error(`Sync Read failed: ${err.message}`);
|
668 |
+
}
|
669 |
+
}
|
670 |
+
|
671 |
+
/**
|
672 |
+
* Writes target positions to multiple servos synchronously.
|
673 |
+
* @param {Map<number, number> | object} servoPositions - A Map or object where keys are servo IDs (1-252) and values are target positions (0-4095).
|
674 |
+
* @returns {Promise<"success">} Resolves with "success".
|
675 |
+
* @throws {Error} If not connected, any position is out of range, transmission fails, or an exception occurs.
|
676 |
+
*/
|
677 |
+
export async function syncWritePositions(servoPositions) {
|
678 |
+
checkConnection();
|
679 |
+
|
680 |
+
const groupSyncWrite = new GroupSyncWrite(
|
681 |
+
portHandler,
|
682 |
+
packetHandler,
|
683 |
+
ADDR_SCS_GOAL_POSITION,
|
684 |
+
2 // Data length for position
|
685 |
+
);
|
686 |
+
let paramAdded = false;
|
687 |
+
|
688 |
+
const entries =
|
689 |
+
servoPositions instanceof Map ? servoPositions.entries() : Object.entries(servoPositions);
|
690 |
+
|
691 |
+
// Second pass: Add valid parameters
|
692 |
+
for (const [idStr, position] of entries) {
|
693 |
+
const servoId = parseInt(idStr, 10); // Already validated
|
694 |
+
if (isNaN(servoId) || servoId < 1 || servoId > 252) {
|
695 |
+
throw new Error(`Invalid servo ID "${idStr}" in syncWritePositions.`);
|
696 |
+
}
|
697 |
+
if (position < 0 || position > 4095) {
|
698 |
+
throw new Error(
|
699 |
+
`Invalid position value ${position} for servo ${servoId} in syncWritePositions. Must be between 0 and 4095.`
|
700 |
+
);
|
701 |
+
}
|
702 |
+
const targetPosition = Math.round(position); // Ensure integer, already validated range
|
703 |
+
const data = [SCS_LOBYTE(targetPosition), SCS_HIBYTE(targetPosition)];
|
704 |
+
|
705 |
+
if (groupSyncWrite.addParam(servoId, data)) {
|
706 |
+
paramAdded = true;
|
707 |
+
} else {
|
708 |
+
// This should ideally not happen if IDs are unique, but handle defensively
|
709 |
+
console.warn(`Failed to add servo ${servoId} to sync write group (possibly duplicate).`);
|
710 |
+
}
|
711 |
+
}
|
712 |
+
|
713 |
+
if (!paramAdded) {
|
714 |
+
console.log("Sync Write: No valid servo positions provided or added.");
|
715 |
+
return "success"; // Nothing to write is considered success
|
716 |
+
}
|
717 |
+
|
718 |
+
try {
|
719 |
+
// Send the Sync Write instruction
|
720 |
+
const result = await groupSyncWrite.txPacket();
|
721 |
+
if (result !== COMM_SUCCESS) {
|
722 |
+
throw new Error(`Sync Write txPacket failed: ${packetHandler.getTxRxResult(result)}`);
|
723 |
+
}
|
724 |
+
return "success";
|
725 |
+
} catch (err) {
|
726 |
+
console.error("Exception during syncWritePositions:", err);
|
727 |
+
// Re-throw the original error or a new one wrapping it
|
728 |
+
throw new Error(`Sync Write failed: ${err.message}`);
|
729 |
+
}
|
730 |
+
}
|
731 |
+
|
732 |
+
/**
|
733 |
+
* Sets the Baud Rate of a servo.
|
734 |
+
* NOTE: After changing the baud rate, you might need to disconnect and reconnect
|
735 |
+
* at the new baud rate to communicate with the servo further.
|
736 |
+
* @param {number} servoId - The current ID of the servo to configure (1-252).
|
737 |
+
* @param {number} baudRateIndex - The index representing the new baud rate (0-7).
|
738 |
+
* @returns {Promise<"success">} Resolves with "success".
|
739 |
+
* @throws {Error} If not connected, input is invalid, any step fails, or an exception occurs.
|
740 |
+
*/
|
741 |
+
export async function setBaudRate(servoId, baudRateIndex) {
|
742 |
+
checkConnection();
|
743 |
+
|
744 |
+
// Validate inputs
|
745 |
+
if (servoId < 1 || servoId > 252) {
|
746 |
+
throw new Error(`Invalid servo ID provided: ${servoId}. Must be between 1 and 252.`);
|
747 |
+
}
|
748 |
+
if (baudRateIndex < 0 || baudRateIndex > 7) {
|
749 |
+
throw new Error(`Invalid baudRateIndex: ${baudRateIndex}. Must be between 0 and 7.`);
|
750 |
+
}
|
751 |
+
|
752 |
+
let unlocked = false;
|
753 |
+
try {
|
754 |
+
console.log(`Setting baud rate for servo ${servoId}: Index=${baudRateIndex}`);
|
755 |
+
|
756 |
+
// 1. Unlock servo configuration
|
757 |
+
const [resUnlock, errUnlock] = await packetHandler.write1ByteTxRx(
|
758 |
+
portHandler,
|
759 |
+
servoId,
|
760 |
+
ADDR_SCS_LOCK,
|
761 |
+
0 // 0 to unlock
|
762 |
+
);
|
763 |
+
if (resUnlock !== COMM_SUCCESS) {
|
764 |
+
throw new Error(
|
765 |
+
`Failed to unlock servo ${servoId}: ${packetHandler.getTxRxResult(
|
766 |
+
resUnlock
|
767 |
+
)}, Error: ${errUnlock}`
|
768 |
+
);
|
769 |
+
}
|
770 |
+
unlocked = true;
|
771 |
+
|
772 |
+
// 2. Write new Baud Rate index
|
773 |
+
const [resBaud, errBaud] = await packetHandler.write1ByteTxRx(
|
774 |
+
portHandler,
|
775 |
+
servoId,
|
776 |
+
ADDR_SCS_BAUD_RATE,
|
777 |
+
baudRateIndex
|
778 |
+
);
|
779 |
+
if (resBaud !== COMM_SUCCESS) {
|
780 |
+
throw new Error(
|
781 |
+
`Failed to write baud rate index ${baudRateIndex} to servo ${servoId}: ${packetHandler.getTxRxResult(
|
782 |
+
resBaud
|
783 |
+
)}, Error: ${errBaud}`
|
784 |
+
);
|
785 |
+
}
|
786 |
+
|
787 |
+
// 3. Lock servo configuration
|
788 |
+
const [resLock, errLock] = await packetHandler.write1ByteTxRx(
|
789 |
+
portHandler,
|
790 |
+
servoId,
|
791 |
+
ADDR_SCS_LOCK,
|
792 |
+
1
|
793 |
+
);
|
794 |
+
if (resLock !== COMM_SUCCESS) {
|
795 |
+
throw new Error(
|
796 |
+
`Failed to lock servo ${servoId} after setting baud rate: ${packetHandler.getTxRxResult(
|
797 |
+
resLock
|
798 |
+
)}, Error: ${errLock}.`
|
799 |
+
);
|
800 |
+
}
|
801 |
+
unlocked = false; // Successfully locked
|
802 |
+
|
803 |
+
console.log(
|
804 |
+
`Successfully set baud rate for servo ${servoId}. Index: ${baudRateIndex}. Remember to potentially reconnect with the new baud rate.`
|
805 |
+
);
|
806 |
+
return "success";
|
807 |
+
} catch (err) {
|
808 |
+
console.error(`Exception during setBaudRate for servo ID ${servoId}:`, err);
|
809 |
+
if (unlocked) {
|
810 |
+
await tryLockServo(servoId);
|
811 |
+
}
|
812 |
+
throw new Error(`Failed to set baud rate for servo ${servoId}: ${err.message}`);
|
813 |
+
}
|
814 |
+
}
|
815 |
+
|
816 |
+
/**
|
817 |
+
* Sets the ID of a servo.
|
818 |
+
* NOTE: Changing the ID requires using the new ID for subsequent commands.
|
819 |
+
* @param {number} currentServoId - The current ID of the servo to configure (1-252).
|
820 |
+
* @param {number} newServoId - The new ID to set for the servo (1-252).
|
821 |
+
* @returns {Promise<"success">} Resolves with "success".
|
822 |
+
* @throws {Error} If not connected, input is invalid, any step fails, or an exception occurs.
|
823 |
+
*/
|
824 |
+
export async function setServoId(currentServoId, newServoId) {
|
825 |
+
checkConnection();
|
826 |
+
|
827 |
+
// Validate inputs
|
828 |
+
if (currentServoId < 1 || currentServoId > 252 || newServoId < 1 || newServoId > 252) {
|
829 |
+
throw new Error(
|
830 |
+
`Invalid servo ID provided. Current: ${currentServoId}, New: ${newServoId}. Must be between 1 and 252.`
|
831 |
+
);
|
832 |
+
}
|
833 |
+
|
834 |
+
if (currentServoId === newServoId) {
|
835 |
+
console.log(`Servo ID is already ${newServoId}. No change needed.`);
|
836 |
+
return "success";
|
837 |
+
}
|
838 |
+
|
839 |
+
let unlocked = false;
|
840 |
+
let idWritten = false;
|
841 |
+
try {
|
842 |
+
console.log(`Setting servo ID: From ${currentServoId} to ${newServoId}`);
|
843 |
+
|
844 |
+
// 1. Unlock servo configuration (using current ID)
|
845 |
+
const [resUnlock, errUnlock] = await packetHandler.write1ByteTxRx(
|
846 |
+
portHandler,
|
847 |
+
currentServoId,
|
848 |
+
ADDR_SCS_LOCK,
|
849 |
+
0 // 0 to unlock
|
850 |
+
);
|
851 |
+
if (resUnlock !== COMM_SUCCESS) {
|
852 |
+
throw new Error(
|
853 |
+
`Failed to unlock servo ${currentServoId}: ${packetHandler.getTxRxResult(
|
854 |
+
resUnlock
|
855 |
+
)}, Error: ${errUnlock}`
|
856 |
+
);
|
857 |
+
}
|
858 |
+
unlocked = true;
|
859 |
+
|
860 |
+
// 2. Write new Servo ID (using current ID)
|
861 |
+
const [resId, errId] = await packetHandler.write1ByteTxRx(
|
862 |
+
portHandler,
|
863 |
+
currentServoId,
|
864 |
+
ADDR_SCS_ID,
|
865 |
+
newServoId
|
866 |
+
);
|
867 |
+
if (resId !== COMM_SUCCESS) {
|
868 |
+
throw new Error(
|
869 |
+
`Failed to write new ID ${newServoId} to servo ${currentServoId}: ${packetHandler.getTxRxResult(
|
870 |
+
resId
|
871 |
+
)}, Error: ${errId}`
|
872 |
+
);
|
873 |
+
}
|
874 |
+
idWritten = true;
|
875 |
+
|
876 |
+
// 3. Lock servo configuration (using NEW ID)
|
877 |
+
const [resLock, errLock] = await packetHandler.write1ByteTxRx(
|
878 |
+
portHandler,
|
879 |
+
newServoId, // Use NEW ID here
|
880 |
+
ADDR_SCS_LOCK,
|
881 |
+
1 // 1 to lock
|
882 |
+
);
|
883 |
+
if (resLock !== COMM_SUCCESS) {
|
884 |
+
// ID was likely changed, but lock failed. Critical state.
|
885 |
+
throw new Error(
|
886 |
+
`Failed to lock servo with new ID ${newServoId}: ${packetHandler.getTxRxResult(
|
887 |
+
resLock
|
888 |
+
)}, Error: ${errLock}. Configuration might be incomplete.`
|
889 |
+
);
|
890 |
+
}
|
891 |
+
unlocked = false; // Successfully locked with new ID
|
892 |
+
|
893 |
+
console.log(
|
894 |
+
`Successfully set servo ID from ${currentServoId} to ${newServoId}. Remember to use the new ID for future commands.`
|
895 |
+
);
|
896 |
+
return "success";
|
897 |
+
} catch (err) {
|
898 |
+
console.error(`Exception during setServoId for current ID ${currentServoId}:`, err);
|
899 |
+
if (unlocked) {
|
900 |
+
// If unlock succeeded but subsequent steps failed, attempt to re-lock.
|
901 |
+
// If ID write failed, use current ID. If ID write succeeded but lock failed, use new ID.
|
902 |
+
const idToLock = idWritten ? newServoId : currentServoId;
|
903 |
+
console.warn(`Attempting to re-lock servo using ID ${idToLock}...`);
|
904 |
+
await tryLockServo(idToLock);
|
905 |
+
}
|
906 |
+
throw new Error(
|
907 |
+
`Failed to set servo ID from ${currentServoId} to ${newServoId}: ${err.message}`
|
908 |
+
);
|
909 |
+
}
|
910 |
+
}
|
packages/feetech.js/scsServoSDKUnlock.mjs
ADDED
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Read-Only Servo SDK for USB Master
|
3 |
+
* Simplified version that only reads positions without any locking
|
4 |
+
*/
|
5 |
+
|
6 |
+
import { PortHandler, PacketHandler, COMM_SUCCESS, GroupSyncRead } from "./lowLevelSDK.mjs";
|
7 |
+
|
8 |
+
import { ADDR_SCS_PRESENT_POSITION } from "./scsservo_constants.mjs";
|
9 |
+
|
10 |
+
// Module-level variables for handlers
|
11 |
+
let portHandler = null;
|
12 |
+
let packetHandler = null;
|
13 |
+
|
14 |
+
/**
|
15 |
+
* Connects to the serial port and initializes handlers.
|
16 |
+
* @param {object} [options] - Connection options.
|
17 |
+
* @param {number} [options.baudRate=1000000] - The baud rate for the serial connection.
|
18 |
+
* @param {number} [options.protocolEnd=0] - The protocol end setting (0 for STS/SMS, 1 for SCS).
|
19 |
+
* @returns {Promise<true>} Resolves with true on successful connection.
|
20 |
+
* @throws {Error} If connection fails or port cannot be opened/selected.
|
21 |
+
*/
|
22 |
+
export async function connect(options = {}) {
|
23 |
+
if (portHandler && portHandler.isOpen) {
|
24 |
+
console.log("🔓 Already connected to USB robot (read-only mode).");
|
25 |
+
return true;
|
26 |
+
}
|
27 |
+
|
28 |
+
const { baudRate = 1000000, protocolEnd = 0 } = options;
|
29 |
+
|
30 |
+
try {
|
31 |
+
portHandler = new PortHandler();
|
32 |
+
const portRequested = await portHandler.requestPort();
|
33 |
+
if (!portRequested) {
|
34 |
+
portHandler = null;
|
35 |
+
throw new Error("Failed to select a serial port.");
|
36 |
+
}
|
37 |
+
|
38 |
+
portHandler.setBaudRate(baudRate);
|
39 |
+
const portOpened = await portHandler.openPort();
|
40 |
+
if (!portOpened) {
|
41 |
+
await portHandler.closePort().catch(console.error);
|
42 |
+
portHandler = null;
|
43 |
+
throw new Error(`Failed to open port at baudrate ${baudRate}.`);
|
44 |
+
}
|
45 |
+
|
46 |
+
packetHandler = new PacketHandler(protocolEnd);
|
47 |
+
console.log(
|
48 |
+
`🔓 Connected to USB robot (read-only mode) at ${baudRate} baud, protocol end: ${protocolEnd}.`
|
49 |
+
);
|
50 |
+
return true;
|
51 |
+
} catch (err) {
|
52 |
+
console.error("Error during USB robot connection:", err);
|
53 |
+
if (portHandler) {
|
54 |
+
try {
|
55 |
+
await portHandler.closePort();
|
56 |
+
} catch (closeErr) {
|
57 |
+
console.error("Error closing port after connection failure:", closeErr);
|
58 |
+
}
|
59 |
+
}
|
60 |
+
portHandler = null;
|
61 |
+
packetHandler = null;
|
62 |
+
throw new Error(`USB robot connection failed: ${err.message}`);
|
63 |
+
}
|
64 |
+
}
|
65 |
+
|
66 |
+
/**
|
67 |
+
* Disconnects from the serial port.
|
68 |
+
* @returns {Promise<true>} Resolves with true on successful disconnection.
|
69 |
+
* @throws {Error} If disconnection fails.
|
70 |
+
*/
|
71 |
+
export async function disconnect() {
|
72 |
+
if (!portHandler || !portHandler.isOpen) {
|
73 |
+
console.log("Already disconnected from USB robot.");
|
74 |
+
return true;
|
75 |
+
}
|
76 |
+
|
77 |
+
try {
|
78 |
+
await portHandler.closePort();
|
79 |
+
portHandler = null;
|
80 |
+
packetHandler = null;
|
81 |
+
console.log("🔓 Disconnected from USB robot (read-only mode).");
|
82 |
+
return true;
|
83 |
+
} catch (err) {
|
84 |
+
console.error("Error during USB robot disconnection:", err);
|
85 |
+
portHandler = null;
|
86 |
+
packetHandler = null;
|
87 |
+
throw new Error(`USB robot disconnection failed: ${err.message}`);
|
88 |
+
}
|
89 |
+
}
|
90 |
+
|
91 |
+
/**
|
92 |
+
* Checks if the SDK is connected. Throws an error if not.
|
93 |
+
* @throws {Error} If not connected.
|
94 |
+
*/
|
95 |
+
function checkConnection() {
|
96 |
+
if (!portHandler || !packetHandler) {
|
97 |
+
throw new Error("Not connected to USB robot. Call connect() first.");
|
98 |
+
}
|
99 |
+
}
|
100 |
+
|
101 |
+
/**
|
102 |
+
* Reads the current position of a servo.
|
103 |
+
* @param {number} servoId - The ID of the servo (1-252).
|
104 |
+
* @returns {Promise<number>} Resolves with the position (0-4095).
|
105 |
+
* @throws {Error} If not connected, read fails, or an exception occurs.
|
106 |
+
*/
|
107 |
+
export async function readPosition(servoId) {
|
108 |
+
checkConnection();
|
109 |
+
try {
|
110 |
+
const [position, result, error] = await packetHandler.read2ByteTxRx(
|
111 |
+
portHandler,
|
112 |
+
servoId,
|
113 |
+
ADDR_SCS_PRESENT_POSITION
|
114 |
+
);
|
115 |
+
|
116 |
+
if (result !== COMM_SUCCESS) {
|
117 |
+
throw new Error(
|
118 |
+
`Error reading position from servo ${servoId}: ${packetHandler.getTxRxResult(
|
119 |
+
result
|
120 |
+
)}, Error code: ${error}`
|
121 |
+
);
|
122 |
+
}
|
123 |
+
return position & 0xffff;
|
124 |
+
} catch (err) {
|
125 |
+
console.error(`Exception reading position from servo ${servoId}:`, err);
|
126 |
+
throw new Error(`Exception reading position from servo ${servoId}: ${err.message}`);
|
127 |
+
}
|
128 |
+
}
|
129 |
+
|
130 |
+
/**
|
131 |
+
* Reads the current position of multiple servos synchronously.
|
132 |
+
* Returns positions for all servos that respond, skipping failed ones gracefully.
|
133 |
+
* @param {number[]} servoIds - An array of servo IDs (1-252) to read from.
|
134 |
+
* @returns {Promise<Map<number, number>>} Resolves with a Map where keys are servo IDs and values are positions (0-4095).
|
135 |
+
* @throws {Error} If not connected or transmission fails completely.
|
136 |
+
*/
|
137 |
+
export async function syncReadPositions(servoIds) {
|
138 |
+
checkConnection();
|
139 |
+
if (!Array.isArray(servoIds) || servoIds.length === 0) {
|
140 |
+
console.log("Sync Read: No servo IDs provided.");
|
141 |
+
return new Map();
|
142 |
+
}
|
143 |
+
|
144 |
+
const startAddress = ADDR_SCS_PRESENT_POSITION;
|
145 |
+
const dataLength = 2;
|
146 |
+
const groupSyncRead = new GroupSyncRead(portHandler, packetHandler, startAddress, dataLength);
|
147 |
+
const positions = new Map();
|
148 |
+
const validIds = [];
|
149 |
+
|
150 |
+
// Add parameters for each valid servo ID
|
151 |
+
servoIds.forEach((id) => {
|
152 |
+
if (id >= 1 && id <= 252) {
|
153 |
+
if (groupSyncRead.addParam(id)) {
|
154 |
+
validIds.push(id);
|
155 |
+
} else {
|
156 |
+
console.warn(
|
157 |
+
`Sync Read: Failed to add param for servo ID ${id} (maybe duplicate or invalid).`
|
158 |
+
);
|
159 |
+
}
|
160 |
+
} else {
|
161 |
+
console.warn(`Sync Read: Invalid servo ID ${id} skipped.`);
|
162 |
+
}
|
163 |
+
});
|
164 |
+
|
165 |
+
if (validIds.length === 0) {
|
166 |
+
console.log("Sync Read: No valid servo IDs to read.");
|
167 |
+
return new Map();
|
168 |
+
}
|
169 |
+
|
170 |
+
try {
|
171 |
+
// Send the Sync Read instruction packet
|
172 |
+
let txResult = await groupSyncRead.txPacket();
|
173 |
+
if (txResult !== COMM_SUCCESS) {
|
174 |
+
throw new Error(`Sync Read txPacket failed: ${packetHandler.getTxRxResult(txResult)}`);
|
175 |
+
}
|
176 |
+
|
177 |
+
// Receive the response packets
|
178 |
+
let rxResult = await groupSyncRead.rxPacket();
|
179 |
+
if (rxResult !== COMM_SUCCESS) {
|
180 |
+
console.warn(
|
181 |
+
`Sync Read rxPacket overall result: ${packetHandler.getTxRxResult(
|
182 |
+
rxResult
|
183 |
+
)}. Checking individual servos.`
|
184 |
+
);
|
185 |
+
}
|
186 |
+
|
187 |
+
// Check data availability and retrieve data for each servo
|
188 |
+
const failedIds = [];
|
189 |
+
validIds.forEach((id) => {
|
190 |
+
const isAvailable = groupSyncRead.isAvailable(id, startAddress, dataLength);
|
191 |
+
if (isAvailable) {
|
192 |
+
const position = groupSyncRead.getData(id, startAddress, dataLength);
|
193 |
+
const finalPosition = position & 0xffff;
|
194 |
+
console.log(
|
195 |
+
`🔍 Debug Servo ${id}: raw=${position}, final=${finalPosition}, hex=${position.toString(16)}`
|
196 |
+
);
|
197 |
+
positions.set(id, finalPosition);
|
198 |
+
} else {
|
199 |
+
failedIds.push(id);
|
200 |
+
}
|
201 |
+
});
|
202 |
+
|
203 |
+
// Log failed servos but don't throw error - return available data
|
204 |
+
if (failedIds.length > 0) {
|
205 |
+
console.warn(
|
206 |
+
`Sync Read: Data not available for servo IDs: ${failedIds.join(
|
207 |
+
", "
|
208 |
+
)}. Got ${positions.size}/${validIds.length} servos successfully.`
|
209 |
+
);
|
210 |
+
}
|
211 |
+
|
212 |
+
return positions;
|
213 |
+
} catch (err) {
|
214 |
+
console.error("Exception during syncReadPositions:", err);
|
215 |
+
throw new Error(`Sync Read failed: ${err.message}`);
|
216 |
+
}
|
217 |
+
}
|
{src/lib → packages}/feetech.js/scsservo_constants.mjs
RENAMED
@@ -1,8 +1,8 @@
|
|
1 |
// Constants for FeetTech SCS servos
|
2 |
|
3 |
// Constants
|
4 |
-
export const BROADCAST_ID =
|
5 |
-
export const MAX_ID =
|
6 |
|
7 |
// Protocol instructions
|
8 |
export const INST_PING = 1;
|
@@ -10,19 +10,19 @@ export const INST_READ = 2;
|
|
10 |
export const INST_WRITE = 3;
|
11 |
export const INST_REG_WRITE = 4;
|
12 |
export const INST_ACTION = 5;
|
13 |
-
export const INST_SYNC_WRITE = 131;
|
14 |
-
export const INST_SYNC_READ = 130;
|
15 |
-
export const INST_STATUS = 85;
|
16 |
|
17 |
// Communication results
|
18 |
-
export const COMM_SUCCESS = 0;
|
19 |
-
export const COMM_PORT_BUSY = -1;
|
20 |
-
export const COMM_TX_FAIL = -2;
|
21 |
-
export const COMM_RX_FAIL = -3;
|
22 |
-
export const COMM_TX_ERROR = -4;
|
23 |
-
export const COMM_RX_WAITING = -5;
|
24 |
-
export const COMM_RX_TIMEOUT = -6;
|
25 |
-
export const COMM_RX_CORRUPT = -7;
|
26 |
export const COMM_NOT_AVAILABLE = -9;
|
27 |
|
28 |
// Packet constants
|
@@ -50,4 +50,4 @@ export const ADDR_SCS_TORQUE_ENABLE = 40;
|
|
50 |
export const ADDR_SCS_GOAL_ACC = 41;
|
51 |
export const ADDR_SCS_GOAL_POSITION = 42;
|
52 |
export const ADDR_SCS_GOAL_SPEED = 46;
|
53 |
-
export const ADDR_SCS_PRESENT_POSITION = 56;
|
|
|
1 |
// Constants for FeetTech SCS servos
|
2 |
|
3 |
// Constants
|
4 |
+
export const BROADCAST_ID = 0xfe; // 254
|
5 |
+
export const MAX_ID = 0xfc; // 252
|
6 |
|
7 |
// Protocol instructions
|
8 |
export const INST_PING = 1;
|
|
|
10 |
export const INST_WRITE = 3;
|
11 |
export const INST_REG_WRITE = 4;
|
12 |
export const INST_ACTION = 5;
|
13 |
+
export const INST_SYNC_WRITE = 131; // 0x83
|
14 |
+
export const INST_SYNC_READ = 130; // 0x82
|
15 |
+
export const INST_STATUS = 85; // 0x55, status packet instruction (0x55)
|
16 |
|
17 |
// Communication results
|
18 |
+
export const COMM_SUCCESS = 0; // tx or rx packet communication success
|
19 |
+
export const COMM_PORT_BUSY = -1; // Port is busy (in use)
|
20 |
+
export const COMM_TX_FAIL = -2; // Failed transmit instruction packet
|
21 |
+
export const COMM_RX_FAIL = -3; // Failed get status packet
|
22 |
+
export const COMM_TX_ERROR = -4; // Incorrect instruction packet
|
23 |
+
export const COMM_RX_WAITING = -5; // Now receiving status packet
|
24 |
+
export const COMM_RX_TIMEOUT = -6; // There is no status packet
|
25 |
+
export const COMM_RX_CORRUPT = -7; // Incorrect status packet
|
26 |
export const COMM_NOT_AVAILABLE = -9;
|
27 |
|
28 |
// Packet constants
|
|
|
50 |
export const ADDR_SCS_GOAL_ACC = 41;
|
51 |
export const ADDR_SCS_GOAL_POSITION = 42;
|
52 |
export const ADDR_SCS_GOAL_SPEED = 46;
|
53 |
+
export const ADDR_SCS_PRESENT_POSITION = 56;
|
packages/feetech.js/test.html
ADDED
@@ -0,0 +1,770 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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>Feetech Servo Test</title>
|
7 |
+
<style>
|
8 |
+
body {
|
9 |
+
font-family: sans-serif;
|
10 |
+
line-height: 1.6;
|
11 |
+
padding: 20px;
|
12 |
+
}
|
13 |
+
.container {
|
14 |
+
max-width: 800px;
|
15 |
+
margin: auto;
|
16 |
+
}
|
17 |
+
.section {
|
18 |
+
border: 1px solid #ccc;
|
19 |
+
padding: 15px;
|
20 |
+
margin-bottom: 20px;
|
21 |
+
border-radius: 5px;
|
22 |
+
}
|
23 |
+
h2 {
|
24 |
+
margin-top: 0;
|
25 |
+
}
|
26 |
+
label {
|
27 |
+
display: inline-block;
|
28 |
+
min-width: 100px;
|
29 |
+
margin-bottom: 5px;
|
30 |
+
}
|
31 |
+
input[type="number"],
|
32 |
+
input[type="text"] {
|
33 |
+
width: 100px;
|
34 |
+
padding: 5px;
|
35 |
+
margin-right: 10px;
|
36 |
+
margin-bottom: 10px;
|
37 |
+
}
|
38 |
+
button {
|
39 |
+
padding: 8px 15px;
|
40 |
+
margin-right: 10px;
|
41 |
+
cursor: pointer;
|
42 |
+
}
|
43 |
+
pre {
|
44 |
+
background-color: #f4f4f4;
|
45 |
+
padding: 10px;
|
46 |
+
border: 1px solid #ddd;
|
47 |
+
border-radius: 3px;
|
48 |
+
white-space: pre-wrap;
|
49 |
+
word-wrap: break-word;
|
50 |
+
}
|
51 |
+
.status {
|
52 |
+
font-weight: bold;
|
53 |
+
}
|
54 |
+
.success {
|
55 |
+
color: green;
|
56 |
+
}
|
57 |
+
.error {
|
58 |
+
color: red;
|
59 |
+
}
|
60 |
+
.log-area {
|
61 |
+
margin-top: 10px;
|
62 |
+
}
|
63 |
+
</style>
|
64 |
+
</head>
|
65 |
+
<body>
|
66 |
+
<div class="container">
|
67 |
+
<h1>Feetech Servo Test Page</h1>
|
68 |
+
|
69 |
+
<details class="section">
|
70 |
+
<summary>Key Concepts</summary>
|
71 |
+
<p>Understanding these parameters is crucial for controlling Feetech servos:</p>
|
72 |
+
<ul>
|
73 |
+
<li>
|
74 |
+
<strong>Mode:</strong> Determines the servo's primary function.
|
75 |
+
<ul>
|
76 |
+
<li>
|
77 |
+
<code>Mode 0</code>: Position/Servo Mode. The servo moves to and holds a specific
|
78 |
+
angular position.
|
79 |
+
</li>
|
80 |
+
<li>
|
81 |
+
<code>Mode 1</code>: Wheel/Speed Mode. The servo rotates continuously at a specified
|
82 |
+
speed and direction, like a motor.
|
83 |
+
</li>
|
84 |
+
</ul>
|
85 |
+
Changing the mode requires unlocking, writing the mode value (0 or 1), and locking the
|
86 |
+
configuration.
|
87 |
+
</li>
|
88 |
+
<li>
|
89 |
+
<strong>Position:</strong> In Position Mode (Mode 0), this value represents the target
|
90 |
+
or current angular position of the servo's output shaft.
|
91 |
+
<ul>
|
92 |
+
<li>
|
93 |
+
Range: Typically <code>0</code> to <code>4095</code> (representing a 12-bit
|
94 |
+
resolution).
|
95 |
+
</li>
|
96 |
+
<li>
|
97 |
+
Meaning: Corresponds to the servo's rotational range (e.g., 0-360 degrees or 0-270
|
98 |
+
degrees, depending on the specific servo model). <code>0</code> is one end of the
|
99 |
+
range, <code>4095</code> is the other.
|
100 |
+
</li>
|
101 |
+
</ul>
|
102 |
+
</li>
|
103 |
+
<li>
|
104 |
+
<strong>Speed (Wheel Mode):</strong> In Wheel Mode (Mode 1), this value controls the
|
105 |
+
rotational speed and direction.
|
106 |
+
<ul>
|
107 |
+
<li>
|
108 |
+
Range: Typically <code>-2500</code> to <code>+2500</code>. (Note: Some documentation
|
109 |
+
might mention -1023 to +1023, but the SDK example uses a wider range).
|
110 |
+
</li>
|
111 |
+
<li>
|
112 |
+
Meaning: <code>0</code> stops the wheel. Positive values rotate in one direction
|
113 |
+
(e.g., clockwise), negative values rotate in the opposite direction (e.g.,
|
114 |
+
counter-clockwise). The magnitude determines the speed (larger absolute value means
|
115 |
+
faster rotation).
|
116 |
+
</li>
|
117 |
+
<li>Control Address: <code>ADDR_SCS_GOAL_SPEED</code> (Register 46/47).</li>
|
118 |
+
</ul>
|
119 |
+
</li>
|
120 |
+
<li>
|
121 |
+
<strong>Acceleration:</strong> Controls how quickly the servo changes speed to reach its
|
122 |
+
target position (in Position Mode) or target speed (in Wheel Mode).
|
123 |
+
<ul>
|
124 |
+
<li>Range: Typically <code>0</code> to <code>254</code>.</li>
|
125 |
+
<li>
|
126 |
+
Meaning: Defines the rate of change of speed. The unit is 100 steps/s².
|
127 |
+
<code>0</code> usually means instantaneous acceleration (or minimal delay). Higher
|
128 |
+
values result in slower, smoother acceleration and deceleration. For example, a
|
129 |
+
value of <code>10</code> means the speed changes by 10 * 100 = 1000 steps per
|
130 |
+
second, per second. This helps reduce jerky movements and mechanical stress.
|
131 |
+
</li>
|
132 |
+
<li>Control Address: <code>ADDR_SCS_GOAL_ACC</code> (Register 41).</li>
|
133 |
+
</ul>
|
134 |
+
</li>
|
135 |
+
<li>
|
136 |
+
<strong>Baud Rate:</strong> The speed of communication between the controller and the
|
137 |
+
servo. It must match on both ends. Servos often support multiple baud rates, selectable
|
138 |
+
via an index:
|
139 |
+
<ul>
|
140 |
+
<li>Index 0: 1,000,000 bps</li>
|
141 |
+
<li>Index 1: 500,000 bps</li>
|
142 |
+
<li>Index 2: 250,000 bps</li>
|
143 |
+
<li>Index 3: 128,000 bps</li>
|
144 |
+
<li>Index 4: 115,200 bps</li>
|
145 |
+
<li>Index 5: 76,800 bps</li>
|
146 |
+
<li>Index 6: 57,600 bps</li>
|
147 |
+
<li>Index 7: 38,400 bps</li>
|
148 |
+
</ul>
|
149 |
+
</li>
|
150 |
+
</ul>
|
151 |
+
</details>
|
152 |
+
|
153 |
+
<div class="section">
|
154 |
+
<h2>Connection</h2>
|
155 |
+
<button id="connectBtn">Connect</button>
|
156 |
+
<button id="disconnectBtn">Disconnect</button>
|
157 |
+
<p>Status: <span id="connectionStatus" class="status error">Disconnected</span></p>
|
158 |
+
<label for="baudRate">Baud Rate:</label>
|
159 |
+
<input type="number" id="baudRate" value="1000000" />
|
160 |
+
<label for="protocolEnd">Protocol End (0=STS/SMS, 1=SCS):</label>
|
161 |
+
<input type="number" id="protocolEnd" value="0" min="0" max="1" />
|
162 |
+
</div>
|
163 |
+
|
164 |
+
<div class="section">
|
165 |
+
<h2>Scan Servos</h2>
|
166 |
+
<label for="scanStartId">Start ID:</label>
|
167 |
+
<input type="number" id="scanStartId" value="1" min="1" max="252" />
|
168 |
+
<label for="scanEndId">End ID:</label>
|
169 |
+
<input type="number" id="scanEndId" value="15" min="1" max="252" />
|
170 |
+
<button id="scanServosBtn">Scan</button>
|
171 |
+
<p>Scan Results:</p>
|
172 |
+
<pre id="scanResultsOutput" style="max-height: 200px; overflow-y: auto"></pre>
|
173 |
+
<!-- Added element for results -->
|
174 |
+
</div>
|
175 |
+
|
176 |
+
<div class="section">
|
177 |
+
<h2>Single Servo Control</h2>
|
178 |
+
<label for="servoId">Servo ID:</label>
|
179 |
+
<input type="number" id="servoId" value="1" min="1" max="252" /><br />
|
180 |
+
|
181 |
+
<label for="idWrite">Change servo ID:</label>
|
182 |
+
<input type="number" id="idWrite" value="1" min="1" max="252" />
|
183 |
+
<button id="writeIdBtn">Write</button><br />
|
184 |
+
|
185 |
+
<label for="baudRead">Read Baud Rate:</label>
|
186 |
+
<button id="readBaudBtn">Read</button>
|
187 |
+
<span id="readBaudResult"></span><br />
|
188 |
+
|
189 |
+
<label for="baudWrite">Write Baud Rate Index:</label>
|
190 |
+
<input type="number" id="baudWrite" value="6" min="0" max="7" />
|
191 |
+
<!-- Assuming index 0-7 -->
|
192 |
+
<button id="writeBaudBtn">Write</button><br />
|
193 |
+
|
194 |
+
<label for="positionRead">Read Position:</label>
|
195 |
+
<button id="readPosBtn">Read</button>
|
196 |
+
<span id="readPosResult"></span><br />
|
197 |
+
|
198 |
+
<label for="positionWrite">Write Position:</label>
|
199 |
+
<input type="number" id="positionWrite" value="1000" min="0" max="4095" />
|
200 |
+
<button id="writePosBtn">Write</button><br />
|
201 |
+
|
202 |
+
<label for="torqueEnable">Torque:</label>
|
203 |
+
<button id="torqueEnableBtn">Enable</button>
|
204 |
+
<button id="torqueDisableBtn">Disable</button><br />
|
205 |
+
|
206 |
+
<label for="accelerationWrite">Write Acceleration:</label>
|
207 |
+
<input type="number" id="accelerationWrite" value="50" min="0" max="254" />
|
208 |
+
<button id="writeAccBtn">Write</button><br />
|
209 |
+
|
210 |
+
<label for="wheelMode">Wheel Mode:</label>
|
211 |
+
<button id="setWheelModeBtn">Set Wheel Mode</button>
|
212 |
+
<button id="removeWheelModeBtn">Set Position Mode</button><br />
|
213 |
+
|
214 |
+
<label for="wheelSpeedWrite">Write Wheel Speed:</label>
|
215 |
+
<input type="number" id="wheelSpeedWrite" value="0" min="-2500" max="2500" />
|
216 |
+
<button id="writeWheelSpeedBtn">Write Speed</button>
|
217 |
+
</div>
|
218 |
+
|
219 |
+
<div class="section">
|
220 |
+
<h2>Sync Operations</h2>
|
221 |
+
<label for="syncReadIds">Sync Read IDs (csv):</label>
|
222 |
+
<input type="text" id="syncReadIds" value="1,2,3" style="width: 150px" />
|
223 |
+
<button id="syncReadBtn">Sync Read Positions</button><br />
|
224 |
+
|
225 |
+
<label for="syncWriteData">Sync Write (id:pos,...):</label>
|
226 |
+
<input type="text" id="syncWriteData" value="1:1500,2:2500" style="width: 200px" />
|
227 |
+
<button id="syncWriteBtn">Sync Write Positions</button><br />
|
228 |
+
|
229 |
+
<label for="syncWriteSpeedData">Sync Write Speed (id:speed,...):</label>
|
230 |
+
<input type="text" id="syncWriteSpeedData" value="1:500,2:-1000" style="width: 200px" />
|
231 |
+
<button id="syncWriteSpeedBtn">Sync Write Speeds</button>
|
232 |
+
<!-- New Button -->
|
233 |
+
</div>
|
234 |
+
|
235 |
+
<div class="section">
|
236 |
+
<h2>Log Output</h2>
|
237 |
+
<pre id="logOutput"></pre>
|
238 |
+
</div>
|
239 |
+
</div>
|
240 |
+
|
241 |
+
<script type="module">
|
242 |
+
// Import the scsServoSDK object from index.mjs
|
243 |
+
import { scsServoSDK } from "./index.mjs";
|
244 |
+
// No longer need COMM_SUCCESS etc. here as errors are thrown
|
245 |
+
|
246 |
+
const connectBtn = document.getElementById("connectBtn");
|
247 |
+
const disconnectBtn = document.getElementById("disconnectBtn");
|
248 |
+
const connectionStatus = document.getElementById("connectionStatus");
|
249 |
+
const baudRateInput = document.getElementById("baudRate");
|
250 |
+
const protocolEndInput = document.getElementById("protocolEnd");
|
251 |
+
|
252 |
+
const servoIdInput = document.getElementById("servoId");
|
253 |
+
const readIdBtn = document.getElementById("readIdBtn"); // New
|
254 |
+
const readIdResult = document.getElementById("readIdResult"); // New
|
255 |
+
const idWriteInput = document.getElementById("idWrite"); // New
|
256 |
+
const writeIdBtn = document.getElementById("writeIdBtn"); // New
|
257 |
+
const readBaudBtn = document.getElementById("readBaudBtn"); // New
|
258 |
+
const readBaudResult = document.getElementById("readBaudResult"); // New
|
259 |
+
const baudWriteInput = document.getElementById("baudWrite"); // New
|
260 |
+
const writeBaudBtn = document.getElementById("writeBaudBtn"); // New
|
261 |
+
const readPosBtn = document.getElementById("readPosBtn");
|
262 |
+
const readPosResult = document.getElementById("readPosResult");
|
263 |
+
const positionWriteInput = document.getElementById("positionWrite");
|
264 |
+
const writePosBtn = document.getElementById("writePosBtn");
|
265 |
+
const torqueEnableBtn = document.getElementById("torqueEnableBtn");
|
266 |
+
const torqueDisableBtn = document.getElementById("torqueDisableBtn");
|
267 |
+
const accelerationWriteInput = document.getElementById("accelerationWrite");
|
268 |
+
const writeAccBtn = document.getElementById("writeAccBtn");
|
269 |
+
const setWheelModeBtn = document.getElementById("setWheelModeBtn");
|
270 |
+
const removeWheelModeBtn = document.getElementById("removeWheelModeBtn"); // Get reference to the new button
|
271 |
+
const wheelSpeedWriteInput = document.getElementById("wheelSpeedWrite");
|
272 |
+
const writeWheelSpeedBtn = document.getElementById("writeWheelSpeedBtn");
|
273 |
+
|
274 |
+
const syncReadIdsInput = document.getElementById("syncReadIds");
|
275 |
+
const syncReadBtn = document.getElementById("syncReadBtn");
|
276 |
+
const syncWriteDataInput = document.getElementById("syncWriteData");
|
277 |
+
const syncWriteBtn = document.getElementById("syncWriteBtn");
|
278 |
+
const syncWriteSpeedDataInput = document.getElementById("syncWriteSpeedData"); // New Input
|
279 |
+
const syncWriteSpeedBtn = document.getElementById("syncWriteSpeedBtn"); // New Button
|
280 |
+
const scanServosBtn = document.getElementById("scanServosBtn"); // Get reference to the scan button
|
281 |
+
const scanStartIdInput = document.getElementById("scanStartId"); // Get reference to start ID input
|
282 |
+
const scanEndIdInput = document.getElementById("scanEndId"); // Get reference to end ID input
|
283 |
+
const scanResultsOutput = document.getElementById("scanResultsOutput"); // Get reference to the new results area
|
284 |
+
|
285 |
+
const logOutput = document.getElementById("logOutput");
|
286 |
+
|
287 |
+
let isConnected = false;
|
288 |
+
|
289 |
+
function log(message) {
|
290 |
+
console.log(message);
|
291 |
+
const timestamp = new Date().toLocaleTimeString();
|
292 |
+
logOutput.textContent = `[${timestamp}] ${message}\n` + logOutput.textContent;
|
293 |
+
// Limit log size
|
294 |
+
const lines = logOutput.textContent.split("\n"); // Use '\n' instead of literal newline
|
295 |
+
if (lines.length > 50) {
|
296 |
+
logOutput.textContent = lines.slice(0, 50).join("\n"); // Use '\n' instead of literal newline
|
297 |
+
}
|
298 |
+
}
|
299 |
+
|
300 |
+
function updateConnectionStatus(connected, message) {
|
301 |
+
isConnected = connected;
|
302 |
+
connectionStatus.textContent = message || (connected ? "Connected" : "Disconnected");
|
303 |
+
connectionStatus.className = `status ${connected ? "success" : "error"}`;
|
304 |
+
log(`Connection status: ${connectionStatus.textContent}`);
|
305 |
+
}
|
306 |
+
|
307 |
+
connectBtn.onclick = async () => {
|
308 |
+
log("Attempting to connect...");
|
309 |
+
try {
|
310 |
+
const baudRate = parseInt(baudRateInput.value, 10);
|
311 |
+
const protocolEnd = parseInt(protocolEndInput.value, 10);
|
312 |
+
// Use scsServoSDK - throws on error
|
313 |
+
await scsServoSDK.connect({ baudRate, protocolEnd });
|
314 |
+
updateConnectionStatus(true, "Connected");
|
315 |
+
} catch (err) {
|
316 |
+
updateConnectionStatus(false, `Connection error: ${err.message}`);
|
317 |
+
console.error(err);
|
318 |
+
}
|
319 |
+
};
|
320 |
+
|
321 |
+
disconnectBtn.onclick = async () => {
|
322 |
+
log("Attempting to disconnect...");
|
323 |
+
try {
|
324 |
+
// Use scsServoSDK - throws on error
|
325 |
+
await scsServoSDK.disconnect();
|
326 |
+
updateConnectionStatus(false, "Disconnected"); // Success means disconnected
|
327 |
+
} catch (err) {
|
328 |
+
// Assuming disconnect might fail if already disconnected or other issues
|
329 |
+
updateConnectionStatus(false, `Disconnection error: ${err.message}`);
|
330 |
+
console.error(err);
|
331 |
+
}
|
332 |
+
};
|
333 |
+
|
334 |
+
writeIdBtn.onclick = async () => {
|
335 |
+
// New handler
|
336 |
+
if (!isConnected) {
|
337 |
+
log("Error: Not connected");
|
338 |
+
return;
|
339 |
+
}
|
340 |
+
const currentId = parseInt(servoIdInput.value, 10);
|
341 |
+
const newId = parseInt(idWriteInput.value, 10);
|
342 |
+
if (isNaN(newId) || newId < 1 || newId > 252) {
|
343 |
+
log(`Error: Invalid new ID ${newId}. Must be between 1 and 252.`);
|
344 |
+
return;
|
345 |
+
}
|
346 |
+
log(`Writing new ID ${newId} to servo ${currentId}...`);
|
347 |
+
try {
|
348 |
+
// Use scsServoSDK - throws on error
|
349 |
+
await scsServoSDK.setServoId(currentId, newId);
|
350 |
+
log(`Successfully wrote new ID ${newId} to servo (was ${currentId}).`);
|
351 |
+
// IMPORTANT: Update the main ID input to reflect the change
|
352 |
+
servoIdInput.value = newId;
|
353 |
+
log(`Servo ID input field updated to ${newId}.`);
|
354 |
+
} catch (err) {
|
355 |
+
log(`Error writing ID for servo ${currentId}: ${err.message}`);
|
356 |
+
console.error(err);
|
357 |
+
}
|
358 |
+
};
|
359 |
+
|
360 |
+
readBaudBtn.onclick = async () => {
|
361 |
+
// New handler
|
362 |
+
if (!isConnected) {
|
363 |
+
log("Error: Not connected");
|
364 |
+
return;
|
365 |
+
}
|
366 |
+
const id = parseInt(servoIdInput.value, 10);
|
367 |
+
log(`Reading Baud Rate Index for servo ${id}...`);
|
368 |
+
readBaudResult.textContent = "Reading...";
|
369 |
+
try {
|
370 |
+
// Use scsServoSDK - returns value directly or throws
|
371 |
+
const baudRateIndex = await scsServoSDK.readBaudRate(id);
|
372 |
+
readBaudResult.textContent = `Baud Index: ${baudRateIndex}`;
|
373 |
+
log(`Servo ${id} Baud Rate Index: ${baudRateIndex}`);
|
374 |
+
} catch (err) {
|
375 |
+
readBaudResult.textContent = `Error: ${err.message}`;
|
376 |
+
log(`Error reading Baud Rate Index for servo ${id}: ${err.message}`);
|
377 |
+
console.error(err);
|
378 |
+
}
|
379 |
+
};
|
380 |
+
|
381 |
+
writeBaudBtn.onclick = async () => {
|
382 |
+
// New handler
|
383 |
+
if (!isConnected) {
|
384 |
+
log("Error: Not connected");
|
385 |
+
return;
|
386 |
+
}
|
387 |
+
const id = parseInt(servoIdInput.value, 10);
|
388 |
+
const newBaudIndex = parseInt(baudWriteInput.value, 10);
|
389 |
+
if (isNaN(newBaudIndex) || newBaudIndex < 0 || newBaudIndex > 7) {
|
390 |
+
// Adjust max index if needed
|
391 |
+
log(`Error: Invalid new Baud Rate Index ${newBaudIndex}. Check valid range.`);
|
392 |
+
return;
|
393 |
+
}
|
394 |
+
log(`Writing new Baud Rate Index ${newBaudIndex} to servo ${id}...`);
|
395 |
+
try {
|
396 |
+
// Use scsServoSDK - throws on error
|
397 |
+
await scsServoSDK.setBaudRate(id, newBaudIndex);
|
398 |
+
log(`Successfully wrote new Baud Rate Index ${newBaudIndex} to servo ${id}.`);
|
399 |
+
log(
|
400 |
+
`IMPORTANT: You may need to disconnect and reconnect with the new baud rate if it differs from the current connection baud rate.`
|
401 |
+
);
|
402 |
+
} catch (err) {
|
403 |
+
log(`Error writing Baud Rate Index for servo ${id}: ${err.message}`);
|
404 |
+
console.error(err);
|
405 |
+
}
|
406 |
+
};
|
407 |
+
|
408 |
+
readPosBtn.onclick = async () => {
|
409 |
+
if (!isConnected) {
|
410 |
+
log("Error: Not connected");
|
411 |
+
return;
|
412 |
+
}
|
413 |
+
const id = parseInt(servoIdInput.value, 10);
|
414 |
+
log(`Reading position for servo ${id}...`);
|
415 |
+
readPosResult.textContent = "Reading...";
|
416 |
+
try {
|
417 |
+
// Use scsServoSDK - returns value directly or throws
|
418 |
+
const position = await scsServoSDK.readPosition(id);
|
419 |
+
readPosResult.textContent = `Position: ${position}`;
|
420 |
+
log(`Servo ${id} position: ${position}`);
|
421 |
+
} catch (err) {
|
422 |
+
readPosResult.textContent = `Error: ${err.message}`;
|
423 |
+
log(`Error reading position for servo ${id}: ${err.message}`);
|
424 |
+
console.error(err);
|
425 |
+
}
|
426 |
+
};
|
427 |
+
|
428 |
+
writePosBtn.onclick = async () => {
|
429 |
+
if (!isConnected) {
|
430 |
+
log("Error: Not connected");
|
431 |
+
return;
|
432 |
+
}
|
433 |
+
const id = parseInt(servoIdInput.value, 10);
|
434 |
+
const pos = parseInt(positionWriteInput.value, 10);
|
435 |
+
log(`Writing position ${pos} to servo ${id}...`);
|
436 |
+
try {
|
437 |
+
// Use scsServoSDK - throws on error
|
438 |
+
await scsServoSDK.writePosition(id, pos);
|
439 |
+
log(`Successfully wrote position ${pos} to servo ${id}.`);
|
440 |
+
} catch (err) {
|
441 |
+
log(`Error writing position for servo ${id}: ${err.message}`);
|
442 |
+
console.error(err);
|
443 |
+
}
|
444 |
+
};
|
445 |
+
|
446 |
+
torqueEnableBtn.onclick = async () => {
|
447 |
+
if (!isConnected) {
|
448 |
+
log("Error: Not connected");
|
449 |
+
return;
|
450 |
+
}
|
451 |
+
const id = parseInt(servoIdInput.value, 10);
|
452 |
+
log(`Enabling torque for servo ${id}...`);
|
453 |
+
try {
|
454 |
+
// Use scsServoSDK - throws on error
|
455 |
+
await scsServoSDK.writeTorqueEnable(id, true);
|
456 |
+
log(`Successfully enabled torque for servo ${id}.`);
|
457 |
+
} catch (err) {
|
458 |
+
log(`Error enabling torque for servo ${id}: ${err.message}`);
|
459 |
+
console.error(err);
|
460 |
+
}
|
461 |
+
};
|
462 |
+
|
463 |
+
torqueDisableBtn.onclick = async () => {
|
464 |
+
if (!isConnected) {
|
465 |
+
log("Error: Not connected");
|
466 |
+
return;
|
467 |
+
}
|
468 |
+
const id = parseInt(servoIdInput.value, 10);
|
469 |
+
log(`Disabling torque for servo ${id}...`);
|
470 |
+
try {
|
471 |
+
// Use scsServoSDK - throws on error
|
472 |
+
await scsServoSDK.writeTorqueEnable(id, false);
|
473 |
+
log(`Successfully disabled torque for servo ${id}.`);
|
474 |
+
} catch (err) {
|
475 |
+
log(`Error disabling torque for servo ${id}: ${err.message}`);
|
476 |
+
console.error(err);
|
477 |
+
}
|
478 |
+
};
|
479 |
+
|
480 |
+
writeAccBtn.onclick = async () => {
|
481 |
+
if (!isConnected) {
|
482 |
+
log("Error: Not connected");
|
483 |
+
return;
|
484 |
+
}
|
485 |
+
const id = parseInt(servoIdInput.value, 10);
|
486 |
+
const acc = parseInt(accelerationWriteInput.value, 10);
|
487 |
+
log(`Writing acceleration ${acc} to servo ${id}...`);
|
488 |
+
try {
|
489 |
+
// Use scsServoSDK - throws on error
|
490 |
+
await scsServoSDK.writeAcceleration(id, acc);
|
491 |
+
log(`Successfully wrote acceleration ${acc} to servo ${id}.`);
|
492 |
+
} catch (err) {
|
493 |
+
log(`Error writing acceleration for servo ${id}: ${err.message}`);
|
494 |
+
console.error(err);
|
495 |
+
}
|
496 |
+
};
|
497 |
+
|
498 |
+
setWheelModeBtn.onclick = async () => {
|
499 |
+
if (!isConnected) {
|
500 |
+
log("Error: Not connected");
|
501 |
+
return;
|
502 |
+
}
|
503 |
+
const id = parseInt(servoIdInput.value, 10);
|
504 |
+
log(`Setting servo ${id} to wheel mode...`);
|
505 |
+
try {
|
506 |
+
// Use scsServoSDK - throws on error
|
507 |
+
await scsServoSDK.setWheelMode(id);
|
508 |
+
log(`Successfully set servo ${id} to wheel mode.`);
|
509 |
+
} catch (err) {
|
510 |
+
log(`Error setting wheel mode for servo ${id}: ${err.message}`);
|
511 |
+
console.error(err);
|
512 |
+
}
|
513 |
+
};
|
514 |
+
|
515 |
+
// Add event listener for the new button
|
516 |
+
removeWheelModeBtn.onclick = async () => {
|
517 |
+
if (!isConnected) {
|
518 |
+
log("Error: Not connected");
|
519 |
+
return;
|
520 |
+
}
|
521 |
+
const id = parseInt(servoIdInput.value, 10);
|
522 |
+
log(`Setting servo ${id} back to position mode...`);
|
523 |
+
try {
|
524 |
+
// Use scsServoSDK - throws on error
|
525 |
+
await scsServoSDK.setPositionMode(id);
|
526 |
+
log(`Successfully set servo ${id} back to position mode.`);
|
527 |
+
} catch (err) {
|
528 |
+
log(`Error setting position mode for servo ${id}: ${err.message}`);
|
529 |
+
console.error(err);
|
530 |
+
}
|
531 |
+
};
|
532 |
+
|
533 |
+
writeWheelSpeedBtn.onclick = async () => {
|
534 |
+
if (!isConnected) {
|
535 |
+
log("Error: Not connected");
|
536 |
+
return;
|
537 |
+
}
|
538 |
+
const id = parseInt(servoIdInput.value, 10);
|
539 |
+
const speed = parseInt(wheelSpeedWriteInput.value, 10);
|
540 |
+
log(`Writing wheel speed ${speed} to servo ${id}...`);
|
541 |
+
try {
|
542 |
+
// Use scsServoSDK - throws on error
|
543 |
+
await scsServoSDK.writeWheelSpeed(id, speed);
|
544 |
+
log(`Successfully wrote wheel speed ${speed} to servo ${id}.`);
|
545 |
+
} catch (err) {
|
546 |
+
log(`Error writing wheel speed for servo ${id}: ${err.message}`);
|
547 |
+
console.error(err);
|
548 |
+
}
|
549 |
+
};
|
550 |
+
|
551 |
+
syncReadBtn.onclick = async () => {
|
552 |
+
if (!isConnected) {
|
553 |
+
log("Error: Not connected");
|
554 |
+
return;
|
555 |
+
}
|
556 |
+
const idsString = syncReadIdsInput.value;
|
557 |
+
const ids = idsString
|
558 |
+
.split(",")
|
559 |
+
.map((s) => parseInt(s.trim(), 10))
|
560 |
+
.filter((id) => !isNaN(id) && id > 0 && id < 253);
|
561 |
+
if (ids.length === 0) {
|
562 |
+
log("Sync Read: No valid servo IDs provided.");
|
563 |
+
return;
|
564 |
+
}
|
565 |
+
log(`Sync reading positions for servos: ${ids.join(", ")}...`);
|
566 |
+
try {
|
567 |
+
// Use scsServoSDK - returns Map or throws
|
568 |
+
const positions = await scsServoSDK.syncReadPositions(ids);
|
569 |
+
let logMsg = "Sync Read Successful:\n";
|
570 |
+
positions.forEach((pos, id) => {
|
571 |
+
logMsg += ` Servo ${id}: Position=${pos}\n`;
|
572 |
+
});
|
573 |
+
log(logMsg.trim());
|
574 |
+
} catch (err) {
|
575 |
+
log(`Sync Read Failed: ${err.message}`);
|
576 |
+
console.error(err);
|
577 |
+
}
|
578 |
+
};
|
579 |
+
|
580 |
+
syncWriteBtn.onclick = async () => {
|
581 |
+
if (!isConnected) {
|
582 |
+
log("Error: Not connected");
|
583 |
+
return;
|
584 |
+
}
|
585 |
+
const dataString = syncWriteDataInput.value;
|
586 |
+
const positionMap = new Map();
|
587 |
+
const pairs = dataString.split(",");
|
588 |
+
let validData = false;
|
589 |
+
|
590 |
+
pairs.forEach((pair) => {
|
591 |
+
const parts = pair.split(":");
|
592 |
+
if (parts.length === 2) {
|
593 |
+
const id = parseInt(parts[0].trim(), 10);
|
594 |
+
const pos = parseInt(parts[1].trim(), 10);
|
595 |
+
// Position validation (0-4095)
|
596 |
+
if (!isNaN(id) && id > 0 && id < 253 && !isNaN(pos) && pos >= 0 && pos <= 4095) {
|
597 |
+
positionMap.set(id, pos);
|
598 |
+
validData = true;
|
599 |
+
} else {
|
600 |
+
log(
|
601 |
+
`Sync Write Position: Invalid data pair "${pair}". ID (1-252), Pos (0-4095). Skipping.`
|
602 |
+
);
|
603 |
+
}
|
604 |
+
} else {
|
605 |
+
log(`Sync Write Position: Invalid format "${pair}". Skipping.`);
|
606 |
+
}
|
607 |
+
});
|
608 |
+
|
609 |
+
if (!validData) {
|
610 |
+
log("Sync Write Position: No valid servo position data provided.");
|
611 |
+
return;
|
612 |
+
}
|
613 |
+
|
614 |
+
log(
|
615 |
+
`Sync writing positions: ${Array.from(positionMap.entries())
|
616 |
+
.map(([id, pos]) => `${id}:${pos}`)
|
617 |
+
.join(", ")}...`
|
618 |
+
);
|
619 |
+
try {
|
620 |
+
// Use scsServoSDK - throws on error
|
621 |
+
await scsServoSDK.syncWritePositions(positionMap);
|
622 |
+
log(`Sync write position command sent successfully.`);
|
623 |
+
} catch (err) {
|
624 |
+
log(`Sync Write Position Failed: ${err.message}`);
|
625 |
+
console.error(err);
|
626 |
+
}
|
627 |
+
};
|
628 |
+
|
629 |
+
// New handler for Sync Write Speed
|
630 |
+
syncWriteSpeedBtn.onclick = async () => {
|
631 |
+
if (!isConnected) {
|
632 |
+
log("Error: Not connected");
|
633 |
+
return;
|
634 |
+
}
|
635 |
+
const dataString = syncWriteSpeedDataInput.value;
|
636 |
+
const speedMap = new Map();
|
637 |
+
const pairs = dataString.split(",");
|
638 |
+
let validData = false;
|
639 |
+
|
640 |
+
pairs.forEach((pair) => {
|
641 |
+
const parts = pair.split(":");
|
642 |
+
if (parts.length === 2) {
|
643 |
+
const id = parseInt(parts[0].trim(), 10);
|
644 |
+
const speed = parseInt(parts[1].trim(), 10);
|
645 |
+
// Speed validation (-10000 to 10000)
|
646 |
+
if (
|
647 |
+
!isNaN(id) &&
|
648 |
+
id > 0 &&
|
649 |
+
id < 253 &&
|
650 |
+
!isNaN(speed) &&
|
651 |
+
speed >= -10000 &&
|
652 |
+
speed <= 10000
|
653 |
+
) {
|
654 |
+
speedMap.set(id, speed);
|
655 |
+
validData = true;
|
656 |
+
} else {
|
657 |
+
log(
|
658 |
+
`Sync Write Speed: Invalid data pair "${pair}". ID (1-252), Speed (-10000 to 10000). Skipping.`
|
659 |
+
);
|
660 |
+
}
|
661 |
+
} else {
|
662 |
+
log(`Sync Write Speed: Invalid format "${pair}". Skipping.`);
|
663 |
+
}
|
664 |
+
});
|
665 |
+
|
666 |
+
if (!validData) {
|
667 |
+
log("Sync Write Speed: No valid servo speed data provided.");
|
668 |
+
return;
|
669 |
+
}
|
670 |
+
|
671 |
+
log(
|
672 |
+
`Sync writing speeds: ${Array.from(speedMap.entries())
|
673 |
+
.map(([id, speed]) => `${id}:${speed}`)
|
674 |
+
.join(", ")}...`
|
675 |
+
);
|
676 |
+
try {
|
677 |
+
// Use scsServoSDK - throws on error
|
678 |
+
await scsServoSDK.syncWriteWheelSpeed(speedMap);
|
679 |
+
log(`Sync write speed command sent successfully.`);
|
680 |
+
} catch (err) {
|
681 |
+
log(`Sync Write Speed Failed: ${err.message}`);
|
682 |
+
console.error(err);
|
683 |
+
}
|
684 |
+
};
|
685 |
+
|
686 |
+
scanServosBtn.onclick = async () => {
|
687 |
+
if (!isConnected) {
|
688 |
+
log("Error: Not connected");
|
689 |
+
return;
|
690 |
+
}
|
691 |
+
|
692 |
+
const startId = parseInt(scanStartIdInput.value, 10);
|
693 |
+
const endId = parseInt(scanEndIdInput.value, 10);
|
694 |
+
|
695 |
+
if (isNaN(startId) || isNaN(endId) || startId < 1 || endId > 252 || startId > endId) {
|
696 |
+
const errorMsg =
|
697 |
+
"Error: Invalid scan ID range. Please enter values between 1 and 252, with Start ID <= End ID.";
|
698 |
+
log(errorMsg);
|
699 |
+
scanResultsOutput.textContent = errorMsg; // Show error in results area too
|
700 |
+
return;
|
701 |
+
}
|
702 |
+
|
703 |
+
const startMsg = `Starting servo scan (IDs ${startId}-${endId})...`;
|
704 |
+
log(startMsg);
|
705 |
+
scanResultsOutput.textContent = startMsg + "\n"; // Clear and start results area
|
706 |
+
scanServosBtn.disabled = true; // Disable button during scan
|
707 |
+
|
708 |
+
let foundCount = 0;
|
709 |
+
|
710 |
+
for (let id = startId; id <= endId; id++) {
|
711 |
+
let resultMsg = `Scanning ID ${id}... `;
|
712 |
+
try {
|
713 |
+
// Attempt to read position. If it succeeds, the servo exists.
|
714 |
+
// If it throws, the servo likely doesn't exist or there's another issue.
|
715 |
+
const position = await scsServoSDK.readPosition(id);
|
716 |
+
foundCount++;
|
717 |
+
|
718 |
+
// Servo found, now try to read mode and baud rate
|
719 |
+
let mode = "ReadError";
|
720 |
+
let baudRateIndex = "ReadError";
|
721 |
+
try {
|
722 |
+
mode = await scsServoSDK.readMode(id);
|
723 |
+
} catch (modeErr) {
|
724 |
+
log(` Servo ${id}: Error reading mode: ${modeErr.message}`);
|
725 |
+
}
|
726 |
+
try {
|
727 |
+
baudRateIndex = await scsServoSDK.readBaudRate(id);
|
728 |
+
} catch (baudErr) {
|
729 |
+
log(` Servo ${id}: Error reading baud rate: ${baudErr.message}`);
|
730 |
+
}
|
731 |
+
|
732 |
+
resultMsg += `FOUND: Pos=${position}, Mode=${mode}, BaudIdx=${baudRateIndex}`;
|
733 |
+
log(
|
734 |
+
` Servo ${id} FOUND: Position=${position}, Mode=${mode}, BaudIndex=${baudRateIndex}`
|
735 |
+
);
|
736 |
+
} catch (err) {
|
737 |
+
// Check if the error message indicates a timeout or non-response, which is expected for non-existent IDs
|
738 |
+
// This check might need refinement based on the exact error messages thrown by readPosition
|
739 |
+
if (
|
740 |
+
err.message.includes("timeout") ||
|
741 |
+
err.message.includes("No response") ||
|
742 |
+
err.message.includes("failed: RX")
|
743 |
+
) {
|
744 |
+
resultMsg += `No response`;
|
745 |
+
// log(` Servo ${id}: No response`); // Optional: reduce log noise
|
746 |
+
} else {
|
747 |
+
// Log other unexpected errors
|
748 |
+
resultMsg += `Error: ${err.message}`;
|
749 |
+
log(` Servo ${id}: Error during scan: ${err.message}`);
|
750 |
+
console.error(`Error scanning servo ${id}:`, err);
|
751 |
+
}
|
752 |
+
}
|
753 |
+
scanResultsOutput.textContent += resultMsg + "\n"; // Append result to the results area
|
754 |
+
scanResultsOutput.scrollTop = scanResultsOutput.scrollHeight; // Auto-scroll
|
755 |
+
// Optional small delay between scans if needed
|
756 |
+
// await new Promise(resolve => setTimeout(resolve, 10));
|
757 |
+
}
|
758 |
+
|
759 |
+
const finishMsg = `Servo scan finished. Found ${foundCount} servo(s).`;
|
760 |
+
log(finishMsg);
|
761 |
+
scanResultsOutput.textContent += finishMsg + "\n"; // Add finish message to results area
|
762 |
+
scanResultsOutput.scrollTop = scanResultsOutput.scrollHeight; // Auto-scroll
|
763 |
+
scanServosBtn.disabled = false; // Re-enable button
|
764 |
+
};
|
765 |
+
|
766 |
+
// Initial log
|
767 |
+
log("Test page loaded. Please connect to a servo controller.");
|
768 |
+
</script>
|
769 |
+
</body>
|
770 |
+
</html>
|
src-python/README.md
CHANGED
@@ -5,6 +5,7 @@ A comprehensive WebSocket-based robot control platform featuring **master-slave
|
|
5 |
## 🏛️ Core Architecture
|
6 |
|
7 |
### Master-Slave Pattern
|
|
|
8 |
```
|
9 |
┌─────────────────┐ Commands ┌─────────────────┐ Execution ┌─────────────────┐
|
10 |
│ 🎮 MASTERS │ ───────────────►│ 🤖 ROBOT CORE │ ───────────────►│ 🔧 SLAVES │
|
@@ -17,8 +18,9 @@ A comprehensive WebSocket-based robot control platform featuring **master-slave
|
|
17 |
```
|
18 |
|
19 |
**Key Principles:**
|
|
|
20 |
- 🎯 **Single Source of Truth**: Only one master per robot at a time
|
21 |
-
- 🔒 **Safety First**: Manual control disabled when master active
|
22 |
- 🌐 **Multi-Modal**: Same commands work across all slave types
|
23 |
- 🔄 **Real-Time Sync**: Virtual and physical states synchronized
|
24 |
- 📡 **Network Native**: Built for remote operation from day one
|
@@ -26,6 +28,7 @@ A comprehensive WebSocket-based robot control platform featuring **master-slave
|
|
26 |
## 🚀 Quick Start Guide
|
27 |
|
28 |
### 1. Server Setup
|
|
|
29 |
```bash
|
30 |
# Install dependencies
|
31 |
cd src-python
|
@@ -37,6 +40,7 @@ python start_server.py
|
|
37 |
```
|
38 |
|
39 |
### 2. Frontend Integration
|
|
|
40 |
```bash
|
41 |
# In your Svelte app
|
42 |
npm run dev
|
@@ -44,6 +48,7 @@ npm run dev
|
|
44 |
```
|
45 |
|
46 |
### 3. Create & Control Robot
|
|
|
47 |
```javascript
|
48 |
// Create robot in UI or via API
|
49 |
const robot = await robotManager.createRobot('my-robot', robotUrdfConfig);
|
@@ -67,44 +72,49 @@ curl -X POST http://localhost:8080/api/robots/my-robot/play-sequence/gentle-wave
|
|
67 |
## 🎮 Master Drivers (Command Sources)
|
68 |
|
69 |
### MockSequenceMaster
|
|
|
70 |
**Pre-programmed movement patterns for testing and demos**
|
71 |
|
72 |
```typescript
|
73 |
const config: MasterDriverConfig = {
|
74 |
-
|
75 |
-
|
76 |
-
|
77 |
-
|
78 |
};
|
79 |
|
80 |
-
await robotManager.connectMaster(
|
81 |
```
|
82 |
|
83 |
**Available Sequences:**
|
|
|
84 |
- **🌊 Gentle Wave Pattern** (6s): Smooth greeting gesture with wrist movements
|
85 |
-
- **🔍 Small Scanning Pattern** (8s): Horizontal sweep for environment scanning
|
86 |
- **💪 Tiny Flex Pattern** (8s): Articulation demonstration with elbow/jaw coordination
|
87 |
|
88 |
**Features:**
|
|
|
89 |
- ✅ Safe movement ranges (±25° max)
|
90 |
- ✅ Smooth interpolation between keyframes
|
91 |
- ✅ Loop mode for continuous operation
|
92 |
- ✅ Automatic master takeover
|
93 |
|
94 |
### RemoteServerMaster
|
|
|
95 |
**WebSocket connection to external control systems**
|
96 |
|
97 |
```typescript
|
98 |
const config: MasterDriverConfig = {
|
99 |
-
|
100 |
-
|
101 |
-
|
102 |
};
|
103 |
|
104 |
-
await robotManager.connectMaster(
|
105 |
```
|
106 |
|
107 |
**Features:**
|
|
|
108 |
- ✅ Real-time bidirectional communication
|
109 |
- ✅ Automatic reconnection with exponential backoff
|
110 |
- ✅ Heartbeat monitoring (30s intervals)
|
@@ -112,36 +122,37 @@ await robotManager.connectMaster('robot-1', config);
|
|
112 |
- ✅ Status and error reporting
|
113 |
|
114 |
**Message Format:**
|
|
|
115 |
```json
|
116 |
{
|
117 |
-
|
118 |
-
|
119 |
-
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
]
|
124 |
-
}
|
125 |
}
|
126 |
```
|
127 |
|
128 |
## 🔧 Slave Drivers (Execution Targets)
|
129 |
|
130 |
### MockSlave
|
|
|
131 |
**Perfect simulation for development and testing**
|
132 |
|
133 |
```typescript
|
134 |
const config: SlaveDriverConfig = {
|
135 |
-
|
136 |
-
|
137 |
-
|
138 |
-
|
139 |
};
|
140 |
|
141 |
-
await robotManager.connectSlave(
|
142 |
```
|
143 |
|
144 |
**Features:**
|
|
|
145 |
- ✅ Perfect command execution (real_value = virtual_value)
|
146 |
- ✅ Configurable network latency simulation
|
147 |
- ✅ Error injection for robustness testing
|
@@ -149,19 +160,21 @@ await robotManager.connectSlave('robot-1', config);
|
|
149 |
- ✅ Real-time state feedback
|
150 |
|
151 |
### USBSlave
|
|
|
152 |
**Direct serial communication with physical robots**
|
153 |
|
154 |
```typescript
|
155 |
const config: SlaveDriverConfig = {
|
156 |
-
|
157 |
-
|
158 |
-
|
159 |
};
|
160 |
|
161 |
-
await robotManager.connectSlave(
|
162 |
```
|
163 |
|
164 |
**Features:**
|
|
|
165 |
- ✅ Feetech servo protocol support
|
166 |
- ✅ Position and speed control
|
167 |
- ✅ Real servo position feedback
|
@@ -169,25 +182,28 @@ await robotManager.connectSlave('robot-1', config);
|
|
169 |
- ✅ Error handling and recovery
|
170 |
|
171 |
**Calibration Process:**
|
|
|
172 |
1. Manually position robot to match digital twin
|
173 |
2. Click "Calibrate" to sync virtual/real coordinates
|
174 |
3. System calculates offset: `real_pos = raw_pos + offset`
|
175 |
4. All future commands automatically compensated
|
176 |
|
177 |
### WebSocketSlave
|
|
|
178 |
**Remote robot control via WebSocket relay**
|
179 |
|
180 |
```typescript
|
181 |
const config: SlaveDriverConfig = {
|
182 |
-
|
183 |
-
|
184 |
-
|
185 |
};
|
186 |
|
187 |
-
await robotManager.connectSlave(
|
188 |
```
|
189 |
|
190 |
**Use Cases:**
|
|
|
191 |
- 🌐 Control robots across internet
|
192 |
- 🏢 Enterprise robot fleet management
|
193 |
- 🔒 Firewall-friendly robot access
|
@@ -198,104 +214,110 @@ await robotManager.connectSlave('robot-1', config);
|
|
198 |
### Master → Server Communication
|
199 |
|
200 |
#### Send Joint Command
|
|
|
201 |
```json
|
202 |
{
|
203 |
-
|
204 |
-
|
205 |
-
|
206 |
-
|
207 |
-
|
208 |
-
|
209 |
-
|
210 |
-
|
211 |
-
|
212 |
-
|
213 |
-
|
214 |
}
|
215 |
```
|
216 |
|
217 |
#### Send Movement Sequence
|
|
|
218 |
```json
|
219 |
{
|
220 |
-
|
221 |
-
|
222 |
-
|
223 |
-
|
224 |
-
|
225 |
-
|
226 |
-
|
227 |
-
|
228 |
-
|
229 |
-
|
230 |
-
|
231 |
-
|
232 |
-
|
233 |
-
|
234 |
-
|
235 |
-
|
236 |
-
|
237 |
-
|
238 |
-
|
239 |
-
|
240 |
}
|
241 |
```
|
242 |
|
243 |
#### Heartbeat
|
|
|
244 |
```json
|
245 |
{
|
246 |
-
|
247 |
-
|
248 |
}
|
249 |
```
|
250 |
|
251 |
### Slave → Server Communication
|
252 |
|
253 |
#### Status Update
|
|
|
254 |
```json
|
255 |
{
|
256 |
-
|
257 |
-
|
258 |
-
|
259 |
-
|
260 |
-
|
261 |
-
|
262 |
-
|
263 |
}
|
264 |
```
|
265 |
|
266 |
#### Joint State Feedback
|
|
|
267 |
```json
|
268 |
{
|
269 |
-
|
270 |
-
|
271 |
-
|
272 |
-
|
273 |
-
|
274 |
-
|
275 |
-
|
276 |
-
|
277 |
-
|
278 |
-
|
279 |
-
|
280 |
-
|
281 |
-
|
282 |
-
|
283 |
-
|
284 |
-
|
285 |
}
|
286 |
```
|
287 |
|
288 |
#### Error Reporting
|
|
|
289 |
```json
|
290 |
{
|
291 |
-
|
292 |
-
|
293 |
-
|
294 |
-
|
295 |
-
|
296 |
-
|
297 |
-
|
298 |
-
|
299 |
}
|
300 |
```
|
301 |
|
@@ -303,33 +325,34 @@ await robotManager.connectSlave('robot-1', config);
|
|
303 |
|
304 |
### Robot Management
|
305 |
|
306 |
-
| Method | Endpoint
|
307 |
-
|
308 |
-
| GET
|
309 |
-
| GET
|
310 |
-
| POST
|
311 |
-
| GET
|
312 |
-
| GET
|
313 |
-
| DELETE | `/api/robots/{id}`
|
314 |
|
315 |
### Sequence Control
|
316 |
|
317 |
-
| Method | Endpoint
|
318 |
-
|
319 |
-
| GET
|
320 |
-
| POST
|
321 |
-
| POST
|
322 |
|
323 |
### WebSocket Endpoints
|
324 |
|
325 |
-
| Endpoint
|
326 |
-
|
327 |
-
| `/ws/master/{robot_id}` | Send commands
|
328 |
-
| `/ws/slave/{robot_id}`
|
329 |
|
330 |
## 🎯 Usage Scenarios
|
331 |
|
332 |
### 1. 🧪 Development & Testing
|
|
|
333 |
```bash
|
334 |
# Create robot with mock slave for safe testing
|
335 |
robot = await robotManager.createRobot('test-bot', urdfConfig);
|
@@ -338,12 +361,14 @@ await robotManager.connectDemoSequences('test-bot');
|
|
338 |
```
|
339 |
|
340 |
**Perfect for:**
|
|
|
341 |
- Algorithm development without hardware risk
|
342 |
- UI/UX testing with realistic feedback
|
343 |
- Automated testing pipelines
|
344 |
- Demo presentations
|
345 |
|
346 |
### 2. 🦾 Physical Robot Control
|
|
|
347 |
```bash
|
348 |
# Connect real hardware
|
349 |
robot = await robotManager.createRobot('real-bot', urdfConfig);
|
@@ -355,35 +380,39 @@ await robotManager.connectDemoSequences('real-bot');
|
|
355 |
```
|
356 |
|
357 |
**Calibration Workflow:**
|
|
|
358 |
1. Connect USB slave to robot
|
359 |
2. Manually position to match 3D model rest pose
|
360 |
3. Click "Calibrate" to sync coordinate systems
|
361 |
4. Robot now mirrors 3D model movements precisely
|
362 |
|
363 |
### 3. 🌐 Remote Operation
|
|
|
364 |
```bash
|
365 |
# Master controls slave over internet
|
366 |
# Master side:
|
367 |
await robotManager.connectMaster('local-avatar', {
|
368 |
-
type: "remote-server",
|
369 |
url: "wss://robot-farm.com:8080"
|
370 |
});
|
371 |
|
372 |
# Slave side (at robot location):
|
373 |
await robotManager.connectSlave('physical-robot', {
|
374 |
type: "websocket-slave",
|
375 |
-
url: "wss://robot-farm.com:8080",
|
376 |
robotId: "local-avatar"
|
377 |
});
|
378 |
```
|
379 |
|
380 |
**Use Cases:**
|
|
|
381 |
- Telepresence robotics
|
382 |
- Remote maintenance and inspection
|
383 |
- Distributed manufacturing
|
384 |
- Educational robot sharing
|
385 |
|
386 |
### 4. 🤖 Multi-Robot Coordination
|
|
|
387 |
```bash
|
388 |
# One master controlling multiple robots
|
389 |
await robotManager.connectMaster('fleet-commander', masterConfig);
|
@@ -395,12 +424,14 @@ for (const robot of robotFleet) {
|
|
395 |
```
|
396 |
|
397 |
**Applications:**
|
|
|
398 |
- Synchronized dance performances
|
399 |
- Assembly line coordination
|
400 |
- Swarm robotics research
|
401 |
- Entertainment shows
|
402 |
|
403 |
### 5. 🧠 AI Agent Integration
|
|
|
404 |
```javascript
|
405 |
// AI agent as master driver
|
406 |
class AIAgentMaster implements MasterDriver {
|
@@ -417,26 +448,28 @@ await robot.setMaster(new AIAgentMaster(config));
|
|
417 |
## 🔧 Integration Guide
|
418 |
|
419 |
### Frontend Integration (Svelte)
|
|
|
420 |
```typescript
|
421 |
-
import { robotManager } from
|
422 |
-
import { robotUrdfConfigMap } from
|
423 |
|
424 |
// Create robot
|
425 |
-
const robot = await robotManager.createRobot(
|
426 |
|
427 |
-
// Add visualization
|
428 |
-
await robotManager.connectMockSlave(
|
429 |
|
430 |
// Add control
|
431 |
-
await robotManager.connectDemoSequences(
|
432 |
|
433 |
// Monitor state
|
434 |
-
robot.joints.forEach(joint => {
|
435 |
-
|
436 |
});
|
437 |
```
|
438 |
|
439 |
### Backend Integration (Python)
|
|
|
440 |
```python
|
441 |
import asyncio
|
442 |
import websockets
|
@@ -455,7 +488,7 @@ async def robot_controller():
|
|
455 |
}
|
456 |
}
|
457 |
await websocket.send(json.dumps(command))
|
458 |
-
|
459 |
# Listen for responses
|
460 |
async for message in websocket:
|
461 |
data = json.loads(message)
|
@@ -466,6 +499,7 @@ asyncio.run(robot_controller())
|
|
466 |
```
|
467 |
|
468 |
### Hardware Integration (Arduino/C++)
|
|
|
469 |
```cpp
|
470 |
#include <WiFi.h>
|
471 |
#include <WebSocketsClient.h>
|
@@ -477,7 +511,7 @@ void onWebSocketEvent(WStype_t type, uint8_t * payload, size_t length) {
|
|
477 |
if (type == WStype_TEXT) {
|
478 |
DynamicJsonDocument doc(1024);
|
479 |
deserializeJson(doc, payload);
|
480 |
-
|
481 |
if (doc["type"] == "execute_command") {
|
482 |
JsonArray joints = doc["data"]["joints"];
|
483 |
for (JsonObject joint : joints) {
|
@@ -498,22 +532,28 @@ void setup() {
|
|
498 |
## 🛡️ Security & Production
|
499 |
|
500 |
### Authentication
|
|
|
501 |
```typescript
|
502 |
// API key authentication (planned)
|
503 |
-
const master = new RemoteServerMaster(
|
504 |
-
|
505 |
-
|
506 |
-
|
507 |
-
|
|
|
|
|
|
|
508 |
```
|
509 |
|
510 |
### TLS/SSL
|
|
|
511 |
```bash
|
512 |
# Production deployment with SSL
|
513 |
uvicorn main:app --host 0.0.0.0 --port 443 --ssl-keyfile key.pem --ssl-certfile cert.pem
|
514 |
```
|
515 |
|
516 |
### Rate Limiting & Safety
|
|
|
517 |
```python
|
518 |
# Built-in protections
|
519 |
- Command rate limiting (100 commands/second max)
|
@@ -526,11 +566,13 @@ uvicorn main:app --host 0.0.0.0 --port 443 --ssl-keyfile key.pem --ssl-certfile
|
|
526 |
## 🚀 Deployment Options
|
527 |
|
528 |
### Development
|
|
|
529 |
```bash
|
530 |
cd src-python && python start_server.py
|
531 |
```
|
532 |
|
533 |
### Docker
|
|
|
534 |
```dockerfile
|
535 |
FROM python:3.12-slim
|
536 |
COPY src-python/ /app/
|
@@ -541,12 +583,14 @@ CMD ["python", "start_server.py"]
|
|
541 |
```
|
542 |
|
543 |
### Cloud (Railway/Heroku)
|
|
|
544 |
```bash
|
545 |
# Procfile
|
546 |
web: cd src-python && python start_server.py
|
547 |
```
|
548 |
|
549 |
### Raspberry Pi (Edge)
|
|
|
550 |
```bash
|
551 |
# systemd service for autostart
|
552 |
sudo systemctl enable lerobot-arena
|
@@ -556,25 +600,28 @@ sudo systemctl start lerobot-arena
|
|
556 |
## 🧪 Testing & Debugging
|
557 |
|
558 |
### Unit Tests
|
|
|
559 |
```bash
|
560 |
cd src-python
|
561 |
pytest tests/ -v
|
562 |
```
|
563 |
|
564 |
### Integration Tests
|
|
|
565 |
```javascript
|
566 |
// Frontend testing
|
567 |
-
import { expect, test } from
|
568 |
|
569 |
-
test(
|
570 |
-
|
571 |
-
|
572 |
-
|
573 |
-
|
574 |
});
|
575 |
```
|
576 |
|
577 |
### Debug Mode
|
|
|
578 |
```bash
|
579 |
# Enable verbose logging
|
580 |
export LOG_LEVEL=DEBUG
|
@@ -586,6 +633,7 @@ npm run dev
|
|
586 |
```
|
587 |
|
588 |
### Health Monitoring
|
|
|
589 |
```bash
|
590 |
# Check server health
|
591 |
curl http://localhost:8080/
|
@@ -597,18 +645,21 @@ curl http://localhost:8080/api/robots
|
|
597 |
## 🔮 Roadmap
|
598 |
|
599 |
### v2.0 - Enhanced Control
|
|
|
600 |
- [ ] **Script Player Master**: Execute Python/JS scripts
|
601 |
- [ ] **Simulation Slave**: Physics-based simulation
|
602 |
- [ ] **Force Control**: Torque and compliance modes
|
603 |
- [ ] **Vision Integration**: Camera feeds and computer vision
|
604 |
|
605 |
### v2.1 - Enterprise Features
|
|
|
606 |
- [ ] **Authentication**: JWT tokens and user management
|
607 |
- [ ] **Multi-tenancy**: Isolated robot fleets per organization
|
608 |
- [ ] **Monitoring**: Prometheus metrics and Grafana dashboards
|
609 |
- [ ] **Recording**: Command sequences and replay
|
610 |
|
611 |
### v2.2 - Advanced Robotics
|
|
|
612 |
- [ ] **Path Planning**: Trajectory optimization
|
613 |
- [ ] **Collision Detection**: Safety in shared workspaces
|
614 |
- [ ] **AI Integration**: Reinforcement learning environments
|
@@ -617,12 +668,13 @@ curl http://localhost:8080/api/robots
|
|
617 |
## 🤝 Contributing
|
618 |
|
619 |
### Development Setup
|
|
|
620 |
```bash
|
621 |
# Frontend
|
622 |
npm install
|
623 |
npm run dev
|
624 |
|
625 |
-
# Backend
|
626 |
cd src-python
|
627 |
uv sync
|
628 |
python start_server.py
|
@@ -633,11 +685,13 @@ cd src-python && pytest
|
|
633 |
```
|
634 |
|
635 |
### Code Style
|
|
|
636 |
- **TypeScript**: ESLint + Prettier
|
637 |
- **Python**: Black + isort + mypy
|
638 |
- **Commits**: Conventional commits format
|
639 |
|
640 |
### Pull Request Process
|
|
|
641 |
1. Fork repository
|
642 |
2. Create feature branch
|
643 |
3. Add tests for new functionality
|
@@ -653,4 +707,4 @@ MIT License - Feel free to use in commercial and personal projects.
|
|
653 |
|
654 |
**Built with ❤️ for the robotics community**
|
655 |
|
656 |
-
|
|
|
5 |
## 🏛️ Core Architecture
|
6 |
|
7 |
### Master-Slave Pattern
|
8 |
+
|
9 |
```
|
10 |
┌─────────────────┐ Commands ┌─────────────────┐ Execution ┌─────────────────┐
|
11 |
│ 🎮 MASTERS │ ───────────────►│ 🤖 ROBOT CORE │ ───────────────►│ 🔧 SLAVES │
|
|
|
18 |
```
|
19 |
|
20 |
**Key Principles:**
|
21 |
+
|
22 |
- 🎯 **Single Source of Truth**: Only one master per robot at a time
|
23 |
+
- 🔒 **Safety First**: Manual control disabled when master active
|
24 |
- 🌐 **Multi-Modal**: Same commands work across all slave types
|
25 |
- 🔄 **Real-Time Sync**: Virtual and physical states synchronized
|
26 |
- 📡 **Network Native**: Built for remote operation from day one
|
|
|
28 |
## 🚀 Quick Start Guide
|
29 |
|
30 |
### 1. Server Setup
|
31 |
+
|
32 |
```bash
|
33 |
# Install dependencies
|
34 |
cd src-python
|
|
|
40 |
```
|
41 |
|
42 |
### 2. Frontend Integration
|
43 |
+
|
44 |
```bash
|
45 |
# In your Svelte app
|
46 |
npm run dev
|
|
|
48 |
```
|
49 |
|
50 |
### 3. Create & Control Robot
|
51 |
+
|
52 |
```javascript
|
53 |
// Create robot in UI or via API
|
54 |
const robot = await robotManager.createRobot('my-robot', robotUrdfConfig);
|
|
|
72 |
## 🎮 Master Drivers (Command Sources)
|
73 |
|
74 |
### MockSequenceMaster
|
75 |
+
|
76 |
**Pre-programmed movement patterns for testing and demos**
|
77 |
|
78 |
```typescript
|
79 |
const config: MasterDriverConfig = {
|
80 |
+
type: "mock-sequence",
|
81 |
+
sequences: DEMO_SEQUENCES,
|
82 |
+
autoStart: true,
|
83 |
+
loopMode: true
|
84 |
};
|
85 |
|
86 |
+
await robotManager.connectMaster("robot-1", config);
|
87 |
```
|
88 |
|
89 |
**Available Sequences:**
|
90 |
+
|
91 |
- **🌊 Gentle Wave Pattern** (6s): Smooth greeting gesture with wrist movements
|
92 |
+
- **🔍 Small Scanning Pattern** (8s): Horizontal sweep for environment scanning
|
93 |
- **💪 Tiny Flex Pattern** (8s): Articulation demonstration with elbow/jaw coordination
|
94 |
|
95 |
**Features:**
|
96 |
+
|
97 |
- ✅ Safe movement ranges (±25° max)
|
98 |
- ✅ Smooth interpolation between keyframes
|
99 |
- ✅ Loop mode for continuous operation
|
100 |
- ✅ Automatic master takeover
|
101 |
|
102 |
### RemoteServerMaster
|
103 |
+
|
104 |
**WebSocket connection to external control systems**
|
105 |
|
106 |
```typescript
|
107 |
const config: MasterDriverConfig = {
|
108 |
+
type: "remote-server",
|
109 |
+
url: "ws://localhost:8080",
|
110 |
+
apiKey: "optional-auth-token"
|
111 |
};
|
112 |
|
113 |
+
await robotManager.connectMaster("robot-1", config);
|
114 |
```
|
115 |
|
116 |
**Features:**
|
117 |
+
|
118 |
- ✅ Real-time bidirectional communication
|
119 |
- ✅ Automatic reconnection with exponential backoff
|
120 |
- ✅ Heartbeat monitoring (30s intervals)
|
|
|
122 |
- ✅ Status and error reporting
|
123 |
|
124 |
**Message Format:**
|
125 |
+
|
126 |
```json
|
127 |
{
|
128 |
+
"type": "command",
|
129 |
+
"timestamp": "2024-01-01T12:00:00Z",
|
130 |
+
"data": {
|
131 |
+
"timestamp": 1704110400000,
|
132 |
+
"joints": [{ "name": "Rotation", "value": 45, "speed": 100 }]
|
133 |
+
}
|
|
|
|
|
134 |
}
|
135 |
```
|
136 |
|
137 |
## 🔧 Slave Drivers (Execution Targets)
|
138 |
|
139 |
### MockSlave
|
140 |
+
|
141 |
**Perfect simulation for development and testing**
|
142 |
|
143 |
```typescript
|
144 |
const config: SlaveDriverConfig = {
|
145 |
+
type: "mock-slave",
|
146 |
+
simulateLatency: 50, // Realistic response delay
|
147 |
+
simulateErrors: false, // Random connection issues
|
148 |
+
responseDelay: 20 // Command execution time
|
149 |
};
|
150 |
|
151 |
+
await robotManager.connectSlave("robot-1", config);
|
152 |
```
|
153 |
|
154 |
**Features:**
|
155 |
+
|
156 |
- ✅ Perfect command execution (real_value = virtual_value)
|
157 |
- ✅ Configurable network latency simulation
|
158 |
- ✅ Error injection for robustness testing
|
|
|
160 |
- ✅ Real-time state feedback
|
161 |
|
162 |
### USBSlave
|
163 |
+
|
164 |
**Direct serial communication with physical robots**
|
165 |
|
166 |
```typescript
|
167 |
const config: SlaveDriverConfig = {
|
168 |
+
type: "usb-slave",
|
169 |
+
port: "/dev/ttyUSB0", // Auto-detect if undefined
|
170 |
+
baudRate: 115200
|
171 |
};
|
172 |
|
173 |
+
await robotManager.connectSlave("robot-1", config);
|
174 |
```
|
175 |
|
176 |
**Features:**
|
177 |
+
|
178 |
- ✅ Feetech servo protocol support
|
179 |
- ✅ Position and speed control
|
180 |
- ✅ Real servo position feedback
|
|
|
182 |
- ✅ Error handling and recovery
|
183 |
|
184 |
**Calibration Process:**
|
185 |
+
|
186 |
1. Manually position robot to match digital twin
|
187 |
2. Click "Calibrate" to sync virtual/real coordinates
|
188 |
3. System calculates offset: `real_pos = raw_pos + offset`
|
189 |
4. All future commands automatically compensated
|
190 |
|
191 |
### WebSocketSlave
|
192 |
+
|
193 |
**Remote robot control via WebSocket relay**
|
194 |
|
195 |
```typescript
|
196 |
const config: SlaveDriverConfig = {
|
197 |
+
type: "websocket-slave",
|
198 |
+
url: "ws://robot-proxy:8080",
|
199 |
+
robotId: "remote-arm-1"
|
200 |
};
|
201 |
|
202 |
+
await robotManager.connectSlave("robot-1", config);
|
203 |
```
|
204 |
|
205 |
**Use Cases:**
|
206 |
+
|
207 |
- 🌐 Control robots across internet
|
208 |
- 🏢 Enterprise robot fleet management
|
209 |
- 🔒 Firewall-friendly robot access
|
|
|
214 |
### Master → Server Communication
|
215 |
|
216 |
#### Send Joint Command
|
217 |
+
|
218 |
```json
|
219 |
{
|
220 |
+
"type": "command",
|
221 |
+
"timestamp": "2024-01-01T12:00:00Z",
|
222 |
+
"data": {
|
223 |
+
"timestamp": 1704110400000,
|
224 |
+
"joints": [
|
225 |
+
{ "name": "Rotation", "value": 45, "speed": 100 },
|
226 |
+
{ "name": "Pitch", "value": -30, "speed": 150 }
|
227 |
+
],
|
228 |
+
"duration": 2000,
|
229 |
+
"metadata": { "source": "manual_control" }
|
230 |
+
}
|
231 |
}
|
232 |
```
|
233 |
|
234 |
#### Send Movement Sequence
|
235 |
+
|
236 |
```json
|
237 |
{
|
238 |
+
"type": "sequence",
|
239 |
+
"timestamp": "2024-01-01T12:00:00Z",
|
240 |
+
"data": {
|
241 |
+
"id": "custom-dance",
|
242 |
+
"name": "Custom Dance Sequence",
|
243 |
+
"commands": [
|
244 |
+
{
|
245 |
+
"timestamp": 0,
|
246 |
+
"joints": [{ "name": "Rotation", "value": -30 }],
|
247 |
+
"duration": 1000
|
248 |
+
},
|
249 |
+
{
|
250 |
+
"timestamp": 1000,
|
251 |
+
"joints": [{ "name": "Rotation", "value": 30 }],
|
252 |
+
"duration": 1000
|
253 |
+
}
|
254 |
+
],
|
255 |
+
"total_duration": 2000,
|
256 |
+
"loop": false
|
257 |
+
}
|
258 |
}
|
259 |
```
|
260 |
|
261 |
#### Heartbeat
|
262 |
+
|
263 |
```json
|
264 |
{
|
265 |
+
"type": "heartbeat",
|
266 |
+
"timestamp": "2024-01-01T12:00:00Z"
|
267 |
}
|
268 |
```
|
269 |
|
270 |
### Slave → Server Communication
|
271 |
|
272 |
#### Status Update
|
273 |
+
|
274 |
```json
|
275 |
{
|
276 |
+
"type": "status_update",
|
277 |
+
"timestamp": "2024-01-01T12:00:00Z",
|
278 |
+
"data": {
|
279 |
+
"isConnected": true,
|
280 |
+
"lastConnected": "2024-01-01T11:58:00Z",
|
281 |
+
"error": null
|
282 |
+
}
|
283 |
}
|
284 |
```
|
285 |
|
286 |
#### Joint State Feedback
|
287 |
+
|
288 |
```json
|
289 |
{
|
290 |
+
"type": "joint_states",
|
291 |
+
"timestamp": "2024-01-01T12:00:00Z",
|
292 |
+
"data": [
|
293 |
+
{
|
294 |
+
"name": "Rotation",
|
295 |
+
"servo_id": 1,
|
296 |
+
"type": "revolute",
|
297 |
+
"virtual_value": 45.0,
|
298 |
+
"real_value": 44.8,
|
299 |
+
"limits": {
|
300 |
+
"lower": -180,
|
301 |
+
"upper": 180,
|
302 |
+
"velocity": 200
|
303 |
+
}
|
304 |
+
}
|
305 |
+
]
|
306 |
}
|
307 |
```
|
308 |
|
309 |
#### Error Reporting
|
310 |
+
|
311 |
```json
|
312 |
{
|
313 |
+
"type": "error",
|
314 |
+
"timestamp": "2024-01-01T12:00:00Z",
|
315 |
+
"data": {
|
316 |
+
"code": "SERVO_TIMEOUT",
|
317 |
+
"message": "Servo 3 not responding",
|
318 |
+
"joint": "Elbow",
|
319 |
+
"severity": "warning"
|
320 |
+
}
|
321 |
}
|
322 |
```
|
323 |
|
|
|
325 |
|
326 |
### Robot Management
|
327 |
|
328 |
+
| Method | Endpoint | Description | Example |
|
329 |
+
| ------ | ------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
330 |
+
| GET | `/` | Server status & metrics | `curl http://localhost:8080/` |
|
331 |
+
| GET | `/api/robots` | List all robots | `curl http://localhost:8080/api/robots` |
|
332 |
+
| POST | `/api/robots` | Create new robot | `curl -X POST -H "Content-Type: application/json" -d '{"robot_type":"so-arm100","name":"My Robot"}' http://localhost:8080/api/robots` |
|
333 |
+
| GET | `/api/robots/{id}` | Get robot details | `curl http://localhost:8080/api/robots/robot-123` |
|
334 |
+
| GET | `/api/robots/{id}/status` | Get connection status | `curl http://localhost:8080/api/robots/robot-123/status` |
|
335 |
+
| DELETE | `/api/robots/{id}` | Delete robot | `curl -X DELETE http://localhost:8080/api/robots/robot-123` |
|
336 |
|
337 |
### Sequence Control
|
338 |
|
339 |
+
| Method | Endpoint | Description | Example |
|
340 |
+
| ------ | ----------------------------------------- | ------------------- | ----------------------------------------------------------------------------------- |
|
341 |
+
| GET | `/api/sequences` | List demo sequences | `curl http://localhost:8080/api/sequences` |
|
342 |
+
| POST | `/api/robots/{id}/play-sequence/{seq_id}` | Play sequence | `curl -X POST http://localhost:8080/api/robots/robot-123/play-sequence/gentle-wave` |
|
343 |
+
| POST | `/api/robots/{id}/stop-sequence` | Stop sequences | `curl -X POST http://localhost:8080/api/robots/robot-123/stop-sequence` |
|
344 |
|
345 |
### WebSocket Endpoints
|
346 |
|
347 |
+
| Endpoint | Purpose | Client Type | Example |
|
348 |
+
| ----------------------- | ---------------- | ----------------- | ----------------------------------------- |
|
349 |
+
| `/ws/master/{robot_id}` | Send commands | Control sources | `ws://localhost:8080/ws/master/robot-123` |
|
350 |
+
| `/ws/slave/{robot_id}` | Receive commands | Execution targets | `ws://localhost:8080/ws/slave/robot-123` |
|
351 |
|
352 |
## 🎯 Usage Scenarios
|
353 |
|
354 |
### 1. 🧪 Development & Testing
|
355 |
+
|
356 |
```bash
|
357 |
# Create robot with mock slave for safe testing
|
358 |
robot = await robotManager.createRobot('test-bot', urdfConfig);
|
|
|
361 |
```
|
362 |
|
363 |
**Perfect for:**
|
364 |
+
|
365 |
- Algorithm development without hardware risk
|
366 |
- UI/UX testing with realistic feedback
|
367 |
- Automated testing pipelines
|
368 |
- Demo presentations
|
369 |
|
370 |
### 2. 🦾 Physical Robot Control
|
371 |
+
|
372 |
```bash
|
373 |
# Connect real hardware
|
374 |
robot = await robotManager.createRobot('real-bot', urdfConfig);
|
|
|
380 |
```
|
381 |
|
382 |
**Calibration Workflow:**
|
383 |
+
|
384 |
1. Connect USB slave to robot
|
385 |
2. Manually position to match 3D model rest pose
|
386 |
3. Click "Calibrate" to sync coordinate systems
|
387 |
4. Robot now mirrors 3D model movements precisely
|
388 |
|
389 |
### 3. 🌐 Remote Operation
|
390 |
+
|
391 |
```bash
|
392 |
# Master controls slave over internet
|
393 |
# Master side:
|
394 |
await robotManager.connectMaster('local-avatar', {
|
395 |
+
type: "remote-server",
|
396 |
url: "wss://robot-farm.com:8080"
|
397 |
});
|
398 |
|
399 |
# Slave side (at robot location):
|
400 |
await robotManager.connectSlave('physical-robot', {
|
401 |
type: "websocket-slave",
|
402 |
+
url: "wss://robot-farm.com:8080",
|
403 |
robotId: "local-avatar"
|
404 |
});
|
405 |
```
|
406 |
|
407 |
**Use Cases:**
|
408 |
+
|
409 |
- Telepresence robotics
|
410 |
- Remote maintenance and inspection
|
411 |
- Distributed manufacturing
|
412 |
- Educational robot sharing
|
413 |
|
414 |
### 4. 🤖 Multi-Robot Coordination
|
415 |
+
|
416 |
```bash
|
417 |
# One master controlling multiple robots
|
418 |
await robotManager.connectMaster('fleet-commander', masterConfig);
|
|
|
424 |
```
|
425 |
|
426 |
**Applications:**
|
427 |
+
|
428 |
- Synchronized dance performances
|
429 |
- Assembly line coordination
|
430 |
- Swarm robotics research
|
431 |
- Entertainment shows
|
432 |
|
433 |
### 5. 🧠 AI Agent Integration
|
434 |
+
|
435 |
```javascript
|
436 |
// AI agent as master driver
|
437 |
class AIAgentMaster implements MasterDriver {
|
|
|
448 |
## 🔧 Integration Guide
|
449 |
|
450 |
### Frontend Integration (Svelte)
|
451 |
+
|
452 |
```typescript
|
453 |
+
import { robotManager } from "$lib/robot/RobotManager.svelte";
|
454 |
+
import { robotUrdfConfigMap } from "$lib/configs/robotUrdfConfig";
|
455 |
|
456 |
// Create robot
|
457 |
+
const robot = await robotManager.createRobot("my-robot", robotUrdfConfigMap["so-arm100"]);
|
458 |
|
459 |
+
// Add visualization
|
460 |
+
await robotManager.connectMockSlave("my-robot");
|
461 |
|
462 |
// Add control
|
463 |
+
await robotManager.connectDemoSequences("my-robot", true);
|
464 |
|
465 |
// Monitor state
|
466 |
+
robot.joints.forEach((joint) => {
|
467 |
+
console.log(`${joint.name}: virtual=${joint.virtualValue}° real=${joint.realValue}°`);
|
468 |
});
|
469 |
```
|
470 |
|
471 |
### Backend Integration (Python)
|
472 |
+
|
473 |
```python
|
474 |
import asyncio
|
475 |
import websockets
|
|
|
488 |
}
|
489 |
}
|
490 |
await websocket.send(json.dumps(command))
|
491 |
+
|
492 |
# Listen for responses
|
493 |
async for message in websocket:
|
494 |
data = json.loads(message)
|
|
|
499 |
```
|
500 |
|
501 |
### Hardware Integration (Arduino/C++)
|
502 |
+
|
503 |
```cpp
|
504 |
#include <WiFi.h>
|
505 |
#include <WebSocketsClient.h>
|
|
|
511 |
if (type == WStype_TEXT) {
|
512 |
DynamicJsonDocument doc(1024);
|
513 |
deserializeJson(doc, payload);
|
514 |
+
|
515 |
if (doc["type"] == "execute_command") {
|
516 |
JsonArray joints = doc["data"]["joints"];
|
517 |
for (JsonObject joint : joints) {
|
|
|
532 |
## 🛡️ Security & Production
|
533 |
|
534 |
### Authentication
|
535 |
+
|
536 |
```typescript
|
537 |
// API key authentication (planned)
|
538 |
+
const master = new RemoteServerMaster(
|
539 |
+
{
|
540 |
+
type: "remote-server",
|
541 |
+
url: "wss://secure-robot-farm.com:8080",
|
542 |
+
apiKey: "your-secret-api-key"
|
543 |
+
},
|
544 |
+
robotId
|
545 |
+
);
|
546 |
```
|
547 |
|
548 |
### TLS/SSL
|
549 |
+
|
550 |
```bash
|
551 |
# Production deployment with SSL
|
552 |
uvicorn main:app --host 0.0.0.0 --port 443 --ssl-keyfile key.pem --ssl-certfile cert.pem
|
553 |
```
|
554 |
|
555 |
### Rate Limiting & Safety
|
556 |
+
|
557 |
```python
|
558 |
# Built-in protections
|
559 |
- Command rate limiting (100 commands/second max)
|
|
|
566 |
## 🚀 Deployment Options
|
567 |
|
568 |
### Development
|
569 |
+
|
570 |
```bash
|
571 |
cd src-python && python start_server.py
|
572 |
```
|
573 |
|
574 |
### Docker
|
575 |
+
|
576 |
```dockerfile
|
577 |
FROM python:3.12-slim
|
578 |
COPY src-python/ /app/
|
|
|
583 |
```
|
584 |
|
585 |
### Cloud (Railway/Heroku)
|
586 |
+
|
587 |
```bash
|
588 |
# Procfile
|
589 |
web: cd src-python && python start_server.py
|
590 |
```
|
591 |
|
592 |
### Raspberry Pi (Edge)
|
593 |
+
|
594 |
```bash
|
595 |
# systemd service for autostart
|
596 |
sudo systemctl enable lerobot-arena
|
|
|
600 |
## 🧪 Testing & Debugging
|
601 |
|
602 |
### Unit Tests
|
603 |
+
|
604 |
```bash
|
605 |
cd src-python
|
606 |
pytest tests/ -v
|
607 |
```
|
608 |
|
609 |
### Integration Tests
|
610 |
+
|
611 |
```javascript
|
612 |
// Frontend testing
|
613 |
+
import { expect, test } from "@playwright/test";
|
614 |
|
615 |
+
test("robot creation and control", async ({ page }) => {
|
616 |
+
await page.goto("/");
|
617 |
+
await page.click('[data-testid="create-robot"]');
|
618 |
+
await page.click('[data-testid="connect-demo-sequences"]');
|
619 |
+
await expect(page.locator('[data-testid="robot-status"]')).toContainText("Master + Slaves");
|
620 |
});
|
621 |
```
|
622 |
|
623 |
### Debug Mode
|
624 |
+
|
625 |
```bash
|
626 |
# Enable verbose logging
|
627 |
export LOG_LEVEL=DEBUG
|
|
|
633 |
```
|
634 |
|
635 |
### Health Monitoring
|
636 |
+
|
637 |
```bash
|
638 |
# Check server health
|
639 |
curl http://localhost:8080/
|
|
|
645 |
## 🔮 Roadmap
|
646 |
|
647 |
### v2.0 - Enhanced Control
|
648 |
+
|
649 |
- [ ] **Script Player Master**: Execute Python/JS scripts
|
650 |
- [ ] **Simulation Slave**: Physics-based simulation
|
651 |
- [ ] **Force Control**: Torque and compliance modes
|
652 |
- [ ] **Vision Integration**: Camera feeds and computer vision
|
653 |
|
654 |
### v2.1 - Enterprise Features
|
655 |
+
|
656 |
- [ ] **Authentication**: JWT tokens and user management
|
657 |
- [ ] **Multi-tenancy**: Isolated robot fleets per organization
|
658 |
- [ ] **Monitoring**: Prometheus metrics and Grafana dashboards
|
659 |
- [ ] **Recording**: Command sequences and replay
|
660 |
|
661 |
### v2.2 - Advanced Robotics
|
662 |
+
|
663 |
- [ ] **Path Planning**: Trajectory optimization
|
664 |
- [ ] **Collision Detection**: Safety in shared workspaces
|
665 |
- [ ] **AI Integration**: Reinforcement learning environments
|
|
|
668 |
## 🤝 Contributing
|
669 |
|
670 |
### Development Setup
|
671 |
+
|
672 |
```bash
|
673 |
# Frontend
|
674 |
npm install
|
675 |
npm run dev
|
676 |
|
677 |
+
# Backend
|
678 |
cd src-python
|
679 |
uv sync
|
680 |
python start_server.py
|
|
|
685 |
```
|
686 |
|
687 |
### Code Style
|
688 |
+
|
689 |
- **TypeScript**: ESLint + Prettier
|
690 |
- **Python**: Black + isort + mypy
|
691 |
- **Commits**: Conventional commits format
|
692 |
|
693 |
### Pull Request Process
|
694 |
+
|
695 |
1. Fork repository
|
696 |
2. Create feature branch
|
697 |
3. Add tests for new functionality
|
|
|
707 |
|
708 |
**Built with ❤️ for the robotics community**
|
709 |
|
710 |
+
_LeRobot Arena bridges the gap between digital twins and physical robots, making robotics accessible to developers, researchers, and enthusiasts worldwide._
|
src/app.css
CHANGED
@@ -6,117 +6,117 @@
|
|
6 |
@custom-variant dark (&:is(.dark *));
|
7 |
|
8 |
:root {
|
9 |
-
|
10 |
-
|
11 |
-
|
12 |
-
|
13 |
-
|
14 |
-
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
}
|
42 |
|
43 |
.dark {
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
|
67 |
-
|
68 |
-
|
69 |
-
|
70 |
-
|
71 |
-
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
}
|
76 |
|
77 |
@theme inline {
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
-
|
82 |
-
|
83 |
-
|
84 |
-
|
85 |
-
|
86 |
-
|
87 |
-
|
88 |
-
|
89 |
-
|
90 |
-
|
91 |
-
|
92 |
-
|
93 |
-
|
94 |
-
|
95 |
-
|
96 |
-
|
97 |
-
|
98 |
-
|
99 |
-
|
100 |
-
|
101 |
-
|
102 |
-
|
103 |
-
|
104 |
-
|
105 |
-
|
106 |
-
|
107 |
-
|
108 |
-
|
109 |
-
|
110 |
-
|
111 |
-
|
112 |
-
|
113 |
}
|
114 |
|
115 |
@layer base {
|
116 |
-
|
117 |
-
|
118 |
-
|
119 |
-
|
120 |
-
|
121 |
-
|
122 |
-
}
|
|
|
6 |
@custom-variant dark (&:is(.dark *));
|
7 |
|
8 |
:root {
|
9 |
+
--radius: 0.625rem;
|
10 |
+
--background: oklch(1 0 0);
|
11 |
+
--foreground: oklch(0.147 0.004 49.25);
|
12 |
+
--card: oklch(1 0 0);
|
13 |
+
--card-foreground: oklch(0.147 0.004 49.25);
|
14 |
+
--popover: oklch(1 0 0);
|
15 |
+
--popover-foreground: oklch(0.147 0.004 49.25);
|
16 |
+
--primary: oklch(0.216 0.006 56.043);
|
17 |
+
--primary-foreground: oklch(0.985 0.001 106.423);
|
18 |
+
--secondary: oklch(0.97 0.001 106.424);
|
19 |
+
--secondary-foreground: oklch(0.216 0.006 56.043);
|
20 |
+
--muted: oklch(0.97 0.001 106.424);
|
21 |
+
--muted-foreground: oklch(0.553 0.013 58.071);
|
22 |
+
--accent: oklch(0.97 0.001 106.424);
|
23 |
+
--accent-foreground: oklch(0.216 0.006 56.043);
|
24 |
+
--destructive: oklch(0.577 0.245 27.325);
|
25 |
+
--border: oklch(0.923 0.003 48.717);
|
26 |
+
--input: oklch(0.923 0.003 48.717);
|
27 |
+
--ring: oklch(0.709 0.01 56.259);
|
28 |
+
--chart-1: oklch(0.646 0.222 41.116);
|
29 |
+
--chart-2: oklch(0.6 0.118 184.704);
|
30 |
+
--chart-3: oklch(0.398 0.07 227.392);
|
31 |
+
--chart-4: oklch(0.828 0.189 84.429);
|
32 |
+
--chart-5: oklch(0.769 0.188 70.08);
|
33 |
+
--sidebar: oklch(0.985 0.001 106.423);
|
34 |
+
--sidebar-foreground: oklch(0.147 0.004 49.25);
|
35 |
+
--sidebar-primary: oklch(0.216 0.006 56.043);
|
36 |
+
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
|
37 |
+
--sidebar-accent: oklch(0.97 0.001 106.424);
|
38 |
+
--sidebar-accent-foreground: oklch(0.216 0.006 56.043);
|
39 |
+
--sidebar-border: oklch(0.923 0.003 48.717);
|
40 |
+
--sidebar-ring: oklch(0.709 0.01 56.259);
|
41 |
}
|
42 |
|
43 |
.dark {
|
44 |
+
--background: oklch(0.147 0.004 49.25);
|
45 |
+
--foreground: oklch(0.985 0.001 106.423);
|
46 |
+
--card: oklch(0.216 0.006 56.043);
|
47 |
+
--card-foreground: oklch(0.985 0.001 106.423);
|
48 |
+
--popover: oklch(0.216 0.006 56.043);
|
49 |
+
--popover-foreground: oklch(0.985 0.001 106.423);
|
50 |
+
--primary: oklch(0.923 0.003 48.717);
|
51 |
+
--primary-foreground: oklch(0.216 0.006 56.043);
|
52 |
+
--secondary: oklch(0.268 0.007 34.298);
|
53 |
+
--secondary-foreground: oklch(0.985 0.001 106.423);
|
54 |
+
--muted: oklch(0.268 0.007 34.298);
|
55 |
+
--muted-foreground: oklch(0.709 0.01 56.259);
|
56 |
+
--accent: oklch(0.268 0.007 34.298);
|
57 |
+
--accent-foreground: oklch(0.985 0.001 106.423);
|
58 |
+
--destructive: oklch(0.704 0.191 22.216);
|
59 |
+
--border: oklch(1 0 0 / 10%);
|
60 |
+
--input: oklch(1 0 0 / 15%);
|
61 |
+
--ring: oklch(0.553 0.013 58.071);
|
62 |
+
--chart-1: oklch(0.488 0.243 264.376);
|
63 |
+
--chart-2: oklch(0.696 0.17 162.48);
|
64 |
+
--chart-3: oklch(0.769 0.188 70.08);
|
65 |
+
--chart-4: oklch(0.627 0.265 303.9);
|
66 |
+
--chart-5: oklch(0.645 0.246 16.439);
|
67 |
+
--sidebar: oklch(0.216 0.006 56.043);
|
68 |
+
--sidebar-foreground: oklch(0.985 0.001 106.423);
|
69 |
+
--sidebar-primary: oklch(0.488 0.243 264.376);
|
70 |
+
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
|
71 |
+
--sidebar-accent: oklch(0.268 0.007 34.298);
|
72 |
+
--sidebar-accent-foreground: oklch(0.985 0.001 106.423);
|
73 |
+
--sidebar-border: oklch(1 0 0 / 10%);
|
74 |
+
--sidebar-ring: oklch(0.553 0.013 58.071);
|
75 |
}
|
76 |
|
77 |
@theme inline {
|
78 |
+
--radius-sm: calc(var(--radius) - 4px);
|
79 |
+
--radius-md: calc(var(--radius) - 2px);
|
80 |
+
--radius-lg: var(--radius);
|
81 |
+
--radius-xl: calc(var(--radius) + 4px);
|
82 |
+
--color-background: var(--background);
|
83 |
+
--color-foreground: var(--foreground);
|
84 |
+
--color-card: var(--card);
|
85 |
+
--color-card-foreground: var(--card-foreground);
|
86 |
+
--color-popover: var(--popover);
|
87 |
+
--color-popover-foreground: var(--popover-foreground);
|
88 |
+
--color-primary: var(--primary);
|
89 |
+
--color-primary-foreground: var(--primary-foreground);
|
90 |
+
--color-secondary: var(--secondary);
|
91 |
+
--color-secondary-foreground: var(--secondary-foreground);
|
92 |
+
--color-muted: var(--muted);
|
93 |
+
--color-muted-foreground: var(--muted-foreground);
|
94 |
+
--color-accent: var(--accent);
|
95 |
+
--color-accent-foreground: var(--accent-foreground);
|
96 |
+
--color-destructive: var(--destructive);
|
97 |
+
--color-border: var(--border);
|
98 |
+
--color-input: var(--input);
|
99 |
+
--color-ring: var(--ring);
|
100 |
+
--color-chart-1: var(--chart-1);
|
101 |
+
--color-chart-2: var(--chart-2);
|
102 |
+
--color-chart-3: var(--chart-3);
|
103 |
+
--color-chart-4: var(--chart-4);
|
104 |
+
--color-chart-5: var(--chart-5);
|
105 |
+
--color-sidebar: var(--sidebar);
|
106 |
+
--color-sidebar-foreground: var(--sidebar-foreground);
|
107 |
+
--color-sidebar-primary: var(--sidebar-primary);
|
108 |
+
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
109 |
+
--color-sidebar-accent: var(--sidebar-accent);
|
110 |
+
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
111 |
+
--color-sidebar-border: var(--sidebar-border);
|
112 |
+
--color-sidebar-ring: var(--sidebar-ring);
|
113 |
}
|
114 |
|
115 |
@layer base {
|
116 |
+
* {
|
117 |
+
@apply border-border outline-ring/50;
|
118 |
+
}
|
119 |
+
body {
|
120 |
+
@apply bg-background text-foreground;
|
121 |
+
}
|
122 |
+
}
|
src/lib/components/3d/GridCustom.svelte
ADDED
@@ -0,0 +1,379 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<!-- Credits to Fyrestar for the https://github.com/Fyrestar/THREE.InfiniteGridHelper -->
|
2 |
+
<script lang="ts">
|
3 |
+
import { T, useTask, useThrelte } from "@threlte/core";
|
4 |
+
import { Color, DoubleSide, Plane, Vector3, Mesh } from "three";
|
5 |
+
import * as THREE from "three";
|
6 |
+
|
7 |
+
// Grid shader code with improved precision and stability
|
8 |
+
import { revision } from "@threlte/core";
|
9 |
+
|
10 |
+
// Props
|
11 |
+
let {
|
12 |
+
cellColor = "#71717A",
|
13 |
+
sectionColor = "#707070",
|
14 |
+
cellSize = 1,
|
15 |
+
backgroundColor = "#000000",
|
16 |
+
backgroundOpacity = 0,
|
17 |
+
sectionSize = 10,
|
18 |
+
plane = "xz",
|
19 |
+
gridSize = [20, 20],
|
20 |
+
followCamera = false,
|
21 |
+
infiniteGrid = false,
|
22 |
+
fadeDistance = 100,
|
23 |
+
fadeStrength = 1,
|
24 |
+
fadeOrigin = undefined,
|
25 |
+
cellThickness = 1,
|
26 |
+
sectionThickness = 2,
|
27 |
+
side = DoubleSide,
|
28 |
+
type = "grid",
|
29 |
+
axis = "x",
|
30 |
+
maxRadius = 0,
|
31 |
+
cellDividers = 6,
|
32 |
+
sectionDividers = 2,
|
33 |
+
floorColor = "#2a2a2a",
|
34 |
+
floorOpacity = 0.3,
|
35 |
+
ref = $bindable(),
|
36 |
+
children = undefined,
|
37 |
+
...props
|
38 |
+
} = $props();
|
39 |
+
|
40 |
+
// Shared fade calculation function for both shaders
|
41 |
+
const fadeCalculation = /*glsl*/ `
|
42 |
+
float calculateFade(vec3 worldPos, float viewZ, vec3 fadeOrigin, float fadeDistance, float fadeStrength, float cameraNear, float cameraFar) {
|
43 |
+
float dist = distance(fadeOrigin, worldPos);
|
44 |
+
float fadeFactor = 1.0 - clamp(dist / fadeDistance, 0.0, 1.0);
|
45 |
+
fadeFactor = pow(fadeFactor, fadeStrength);
|
46 |
+
|
47 |
+
float viewDepthFade = 1.0 - clamp((viewZ - cameraNear) / (cameraFar - cameraNear), 0.0, 1.0);
|
48 |
+
viewDepthFade = smoothstep(0.0, 0.3, viewDepthFade);
|
49 |
+
|
50 |
+
return min(fadeFactor, viewDepthFade);
|
51 |
+
}
|
52 |
+
`;
|
53 |
+
|
54 |
+
const vertexShader = /*glsl*/ `
|
55 |
+
varying vec3 localPosition;
|
56 |
+
varying vec4 worldPosition;
|
57 |
+
varying float vViewZ;
|
58 |
+
|
59 |
+
uniform vec3 worldCamProjPosition;
|
60 |
+
uniform vec3 worldPlanePosition;
|
61 |
+
uniform float fadeDistance;
|
62 |
+
uniform bool infiniteGrid;
|
63 |
+
uniform bool followCamera;
|
64 |
+
uniform int coord0, coord1, coord2;
|
65 |
+
|
66 |
+
void main() {
|
67 |
+
localPosition = vec3(position[coord0], position[coord1], position[coord2]);
|
68 |
+
if (infiniteGrid) localPosition *= 1.0 + fadeDistance;
|
69 |
+
|
70 |
+
worldPosition = modelMatrix * vec4(localPosition, 1.0);
|
71 |
+
if (followCamera) {
|
72 |
+
worldPosition.xyz += (worldCamProjPosition - worldPlanePosition);
|
73 |
+
localPosition = (inverse(modelMatrix) * worldPosition).xyz;
|
74 |
+
}
|
75 |
+
|
76 |
+
vec4 mvPosition = viewMatrix * worldPosition;
|
77 |
+
vViewZ = -mvPosition.z;
|
78 |
+
gl_Position = projectionMatrix * mvPosition;
|
79 |
+
}
|
80 |
+
`;
|
81 |
+
|
82 |
+
const fragmentShader = /*glsl*/ `
|
83 |
+
#define PI 3.141592653589793
|
84 |
+
|
85 |
+
varying vec3 localPosition;
|
86 |
+
varying vec4 worldPosition;
|
87 |
+
varying float vViewZ;
|
88 |
+
|
89 |
+
uniform float cellSize, sectionSize, cellThickness, sectionThickness;
|
90 |
+
uniform vec3 cellColor, sectionColor, backgroundColor, fadeOrigin;
|
91 |
+
uniform float backgroundOpacity, fadeDistance, fadeStrength, cameraNear, cameraFar;
|
92 |
+
uniform bool infiniteGrid;
|
93 |
+
uniform int coord0, coord1, coord2, gridType, lineGridCoord;
|
94 |
+
uniform float circleGridMaxRadius, polarCellDividers, polarSectionDividers;
|
95 |
+
|
96 |
+
${fadeCalculation}
|
97 |
+
|
98 |
+
float getSquareGrid(float size, float thickness, vec3 localPos) {
|
99 |
+
vec2 coord = localPos.xy / size;
|
100 |
+
vec2 derivative = fwidth(coord);
|
101 |
+
vec2 grid = abs(fract(coord - 0.5) - 0.5) / derivative;
|
102 |
+
float line = min(grid.x, grid.y) + 1.0 - thickness;
|
103 |
+
return clamp(1.0 - line, 0.0, 1.0);
|
104 |
+
}
|
105 |
+
|
106 |
+
float getLinesGrid(float size, float thickness, vec3 localPos) {
|
107 |
+
float coord = localPos[lineGridCoord] / size;
|
108 |
+
float derivative = fwidth(coord);
|
109 |
+
float line = abs(fract(coord - 0.5) - 0.5) / derivative - thickness * 0.5;
|
110 |
+
return clamp(1.0 - line, 0.0, 1.0);
|
111 |
+
}
|
112 |
+
|
113 |
+
float getCirclesGrid(float size, float thickness, vec3 localPos) {
|
114 |
+
float coord = length(localPos.xy) / size;
|
115 |
+
float derivative = fwidth(coord);
|
116 |
+
float line = abs(fract(coord - 0.5) - 0.5) / derivative - thickness * 0.5;
|
117 |
+
if (!infiniteGrid && circleGridMaxRadius > 0.0 && coord > circleGridMaxRadius + thickness * 0.1) discard;
|
118 |
+
return clamp(1.0 - line, 0.0, 1.0);
|
119 |
+
}
|
120 |
+
|
121 |
+
float getPolarGrid(float size, float thickness, float polarDividers, vec3 localPos) {
|
122 |
+
float rad = length(localPos.xy) / size;
|
123 |
+
vec2 coord = vec2(rad, atan(localPos.x, localPos.y) * polarDividers / PI);
|
124 |
+
vec2 derivative = fwidth(coord);
|
125 |
+
vec2 grid = abs(fract(coord - 0.5) - 0.5) / derivative;
|
126 |
+
float line = min(grid.x, grid.y) + 1.0 - thickness;
|
127 |
+
if (!infiniteGrid && circleGridMaxRadius > 0.0 && rad > circleGridMaxRadius + thickness * 0.1) discard;
|
128 |
+
return clamp(1.0 - line, 0.0, 1.0);
|
129 |
+
}
|
130 |
+
|
131 |
+
void main() {
|
132 |
+
float g1 = 0.0, g2 = 0.0;
|
133 |
+
vec3 localPos = vec3(localPosition[coord0], localPosition[coord1], localPosition[coord2]);
|
134 |
+
|
135 |
+
if (gridType == 0) {
|
136 |
+
g1 = getSquareGrid(cellSize, cellThickness, localPos);
|
137 |
+
g2 = getSquareGrid(sectionSize, sectionThickness, localPos);
|
138 |
+
} else if (gridType == 1) {
|
139 |
+
g1 = getLinesGrid(cellSize, cellThickness, localPos);
|
140 |
+
g2 = getLinesGrid(sectionSize, sectionThickness, localPos);
|
141 |
+
} else if (gridType == 2) {
|
142 |
+
g1 = getCirclesGrid(cellSize, cellThickness, localPos);
|
143 |
+
g2 = getCirclesGrid(sectionSize, sectionThickness, localPos);
|
144 |
+
} else if (gridType == 3) {
|
145 |
+
g1 = getPolarGrid(cellSize, cellThickness, polarCellDividers, localPos);
|
146 |
+
g2 = getPolarGrid(sectionSize, sectionThickness, polarSectionDividers, localPos);
|
147 |
+
}
|
148 |
+
|
149 |
+
float fadeFactor = calculateFade(worldPosition.xyz, vViewZ, fadeOrigin, fadeDistance, fadeStrength, cameraNear, cameraFar);
|
150 |
+
vec3 color = mix(cellColor, sectionColor, clamp(sectionThickness * g2, 0.0, 1.0));
|
151 |
+
float gridAlpha = clamp((g1 + g2) * fadeFactor, 0.0, 1.0);
|
152 |
+
|
153 |
+
if (backgroundOpacity > 0.0) {
|
154 |
+
vec3 finalColor = mix(backgroundColor, color, gridAlpha);
|
155 |
+
float blendedAlpha = clamp(max(gridAlpha, backgroundOpacity * fadeFactor), 0.0, 1.0);
|
156 |
+
gl_FragColor = vec4(finalColor, blendedAlpha);
|
157 |
+
} else {
|
158 |
+
gl_FragColor = vec4(color, gridAlpha);
|
159 |
+
}
|
160 |
+
|
161 |
+
if (gl_FragColor.a < 0.05) discard;
|
162 |
+
|
163 |
+
#include <tonemapping_fragment>
|
164 |
+
#include <${revision < 154 ? "encodings_fragment" : "colorspace_fragment"}>
|
165 |
+
}
|
166 |
+
`;
|
167 |
+
|
168 |
+
// Simple floor shader
|
169 |
+
const floorVertexShader = /*glsl*/ `
|
170 |
+
varying vec3 vWorldPosition;
|
171 |
+
varying float vViewZ;
|
172 |
+
|
173 |
+
void main() {
|
174 |
+
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
|
175 |
+
vWorldPosition = worldPosition.xyz;
|
176 |
+
vec4 mvPosition = viewMatrix * worldPosition;
|
177 |
+
vViewZ = -mvPosition.z;
|
178 |
+
gl_Position = projectionMatrix * mvPosition;
|
179 |
+
}
|
180 |
+
`;
|
181 |
+
|
182 |
+
const floorFragmentShader = /*glsl*/ `
|
183 |
+
uniform vec3 floorColor, fadeOrigin;
|
184 |
+
uniform float floorOpacity, fadeDistance, fadeStrength, cameraNear, cameraFar;
|
185 |
+
varying vec3 vWorldPosition;
|
186 |
+
varying float vViewZ;
|
187 |
+
|
188 |
+
${fadeCalculation}
|
189 |
+
|
190 |
+
void main() {
|
191 |
+
float fadeFactor = calculateFade(vWorldPosition, vViewZ, fadeOrigin, fadeDistance, fadeStrength, cameraNear, cameraFar);
|
192 |
+
float finalOpacity = floorOpacity * fadeFactor;
|
193 |
+
gl_FragColor = vec4(floorColor, finalOpacity);
|
194 |
+
if (gl_FragColor.a < 0.01) discard;
|
195 |
+
}
|
196 |
+
`;
|
197 |
+
|
198 |
+
const mesh = new Mesh();
|
199 |
+
const { invalidate, camera } = useThrelte();
|
200 |
+
const gridPlane = new Plane();
|
201 |
+
const upVector = new Vector3(0, 1, 0);
|
202 |
+
const zeroVector = new Vector3(0, 0, 0);
|
203 |
+
|
204 |
+
const axisToInt: Record<string, number> = { x: 0, y: 1, z: 2 };
|
205 |
+
const planeToAxes: Record<string, string> = { xz: "xzy", xy: "xyz", zy: "zyx" };
|
206 |
+
const gridType = { grid: 0, lines: 1, circular: 2, polar: 3 };
|
207 |
+
|
208 |
+
// Shared uniforms (used by both grid and floor)
|
209 |
+
const sharedUniforms = {
|
210 |
+
fadeOrigin: { value: new Vector3() },
|
211 |
+
fadeDistance: { value: fadeDistance },
|
212 |
+
fadeStrength: { value: fadeStrength },
|
213 |
+
cameraNear: { value: 0.1 },
|
214 |
+
cameraFar: { value: 1000 }
|
215 |
+
};
|
216 |
+
|
217 |
+
// Grid uniforms
|
218 |
+
const uniforms = {
|
219 |
+
...sharedUniforms,
|
220 |
+
cellSize: { value: cellSize },
|
221 |
+
sectionSize: { value: sectionSize },
|
222 |
+
cellColor: { value: new Color(cellColor) },
|
223 |
+
sectionColor: { value: new Color(sectionColor) },
|
224 |
+
backgroundColor: { value: new Color(backgroundColor) },
|
225 |
+
backgroundOpacity: { value: backgroundOpacity },
|
226 |
+
cellThickness: { value: cellThickness },
|
227 |
+
sectionThickness: { value: sectionThickness },
|
228 |
+
infiniteGrid: { value: infiniteGrid },
|
229 |
+
followCamera: { value: followCamera },
|
230 |
+
coord0: { value: 0 },
|
231 |
+
coord1: { value: 2 },
|
232 |
+
coord2: { value: 1 },
|
233 |
+
gridType: { value: gridType.grid },
|
234 |
+
lineGridCoord: { value: axisToInt[axis as keyof typeof axisToInt] || 0 },
|
235 |
+
circleGridMaxRadius: { value: maxRadius },
|
236 |
+
polarCellDividers: { value: cellDividers },
|
237 |
+
polarSectionDividers: { value: sectionDividers },
|
238 |
+
worldCamProjPosition: { value: new Vector3() },
|
239 |
+
worldPlanePosition: { value: new Vector3() }
|
240 |
+
};
|
241 |
+
|
242 |
+
// Floor uniforms (simpler, reusing shared uniforms)
|
243 |
+
const floorUniforms = {
|
244 |
+
...sharedUniforms,
|
245 |
+
floorColor: { value: new Color(floorColor) },
|
246 |
+
floorOpacity: { value: floorOpacity }
|
247 |
+
};
|
248 |
+
|
249 |
+
// Single update effect for all uniforms
|
250 |
+
$effect.pre(() => {
|
251 |
+
const axes = planeToAxes[plane] || "xzy";
|
252 |
+
const [c0, c1, c2] = [axes.charAt(0), axes.charAt(1), axes.charAt(2)].map(
|
253 |
+
(c) => axisToInt[c as keyof typeof axisToInt]
|
254 |
+
);
|
255 |
+
|
256 |
+
// Update grid uniforms
|
257 |
+
Object.assign(uniforms, {
|
258 |
+
coord0: { value: c0 },
|
259 |
+
coord1: { value: c1 },
|
260 |
+
coord2: { value: c2 },
|
261 |
+
cellSize: { value: cellSize },
|
262 |
+
sectionSize: { value: sectionSize },
|
263 |
+
cellThickness: { value: cellThickness },
|
264 |
+
sectionThickness: { value: sectionThickness },
|
265 |
+
backgroundOpacity: { value: backgroundOpacity },
|
266 |
+
infiniteGrid: { value: infiniteGrid },
|
267 |
+
followCamera: { value: followCamera }
|
268 |
+
});
|
269 |
+
|
270 |
+
uniforms.cellColor.value.set(cellColor);
|
271 |
+
uniforms.sectionColor.value.set(sectionColor);
|
272 |
+
uniforms.backgroundColor.value.set(backgroundColor);
|
273 |
+
|
274 |
+
// Update shared uniforms (affects both grid and floor)
|
275 |
+
sharedUniforms.fadeDistance.value = fadeDistance;
|
276 |
+
sharedUniforms.fadeStrength.value = fadeStrength;
|
277 |
+
floorUniforms.floorColor.value.set(floorColor);
|
278 |
+
floorUniforms.floorOpacity.value = floorOpacity;
|
279 |
+
|
280 |
+
// Update camera uniforms
|
281 |
+
if (camera.current && "near" in camera.current && "far" in camera.current) {
|
282 |
+
const cam = camera.current as THREE.PerspectiveCamera;
|
283 |
+
sharedUniforms.cameraNear.value = cam.near;
|
284 |
+
sharedUniforms.cameraFar.value = cam.far;
|
285 |
+
}
|
286 |
+
|
287 |
+
// Update grid type
|
288 |
+
const typeMap = { grid: 0, lines: 1, circular: 2, polar: 3 };
|
289 |
+
uniforms.gridType.value = typeMap[type as keyof typeof typeMap] || 0;
|
290 |
+
if (type === "lines")
|
291 |
+
uniforms.lineGridCoord.value = axisToInt[axis as keyof typeof axisToInt] || 0;
|
292 |
+
if (type === "circular" || type === "polar") {
|
293 |
+
uniforms.circleGridMaxRadius.value = maxRadius;
|
294 |
+
if (type === "polar") {
|
295 |
+
uniforms.polarCellDividers.value = cellDividers;
|
296 |
+
uniforms.polarSectionDividers.value = sectionDividers;
|
297 |
+
}
|
298 |
+
}
|
299 |
+
|
300 |
+
invalidate();
|
301 |
+
});
|
302 |
+
|
303 |
+
// Single task for both grid and floor fade origins
|
304 |
+
useTask(
|
305 |
+
() => {
|
306 |
+
gridPlane.setFromNormalAndCoplanarPoint(upVector, zeroVector).applyMatrix4(mesh.matrixWorld);
|
307 |
+
const material = mesh.material as THREE.ShaderMaterial;
|
308 |
+
if (material?.uniforms) {
|
309 |
+
const { worldCamProjPosition, worldPlanePosition } = material.uniforms;
|
310 |
+
const projectedPoint = gridPlane.projectPoint(
|
311 |
+
camera.current.position,
|
312 |
+
worldCamProjPosition.value
|
313 |
+
);
|
314 |
+
if (!fadeOrigin) sharedUniforms.fadeOrigin.value = projectedPoint;
|
315 |
+
worldPlanePosition.value.set(0, 0, 0).applyMatrix4(mesh.matrixWorld);
|
316 |
+
}
|
317 |
+
},
|
318 |
+
{ autoInvalidate: false }
|
319 |
+
);
|
320 |
+
</script>
|
321 |
+
|
322 |
+
<!-- Shadow-receiving floor underneath -->
|
323 |
+
<T.Mesh rotation={[-Math.PI / 2, 0, 0]} receiveShadow position.y={0} {...props}>
|
324 |
+
<T.PlaneGeometry
|
325 |
+
args={infiniteGrid
|
326 |
+
? [1000, 1000]
|
327 |
+
: typeof gridSize == "number"
|
328 |
+
? [gridSize, gridSize]
|
329 |
+
: gridSize}
|
330 |
+
/>
|
331 |
+
<T.ShadowMaterial
|
332 |
+
transparent={true}
|
333 |
+
opacity={0.3}
|
334 |
+
polygonOffset={true}
|
335 |
+
polygonOffsetFactor={1}
|
336 |
+
polygonOffsetUnits={1}
|
337 |
+
/>
|
338 |
+
</T.Mesh>
|
339 |
+
|
340 |
+
<!-- Fading floor -->
|
341 |
+
<T.Mesh rotation={[-Math.PI / 2, 0, 0]} position.y={0} {...props}>
|
342 |
+
<T.PlaneGeometry
|
343 |
+
args={infiniteGrid
|
344 |
+
? [1000, 1000]
|
345 |
+
: typeof gridSize == "number"
|
346 |
+
? [gridSize, gridSize]
|
347 |
+
: gridSize}
|
348 |
+
/>
|
349 |
+
<T.ShaderMaterial
|
350 |
+
vertexShader={floorVertexShader}
|
351 |
+
fragmentShader={floorFragmentShader}
|
352 |
+
uniforms={floorUniforms}
|
353 |
+
transparent={true}
|
354 |
+
side={THREE.DoubleSide}
|
355 |
+
depthTest={true}
|
356 |
+
depthWrite={false}
|
357 |
+
polygonOffset={true}
|
358 |
+
polygonOffsetFactor={-1}
|
359 |
+
polygonOffsetUnits={-1}
|
360 |
+
/>
|
361 |
+
</T.Mesh>
|
362 |
+
|
363 |
+
<!-- Grid lines -->
|
364 |
+
<T is={mesh} bind:ref frustumCulled={false} position.y={0.005} {...props}>
|
365 |
+
<T.ShaderMaterial
|
366 |
+
{fragmentShader}
|
367 |
+
{vertexShader}
|
368 |
+
{uniforms}
|
369 |
+
transparent
|
370 |
+
{side}
|
371 |
+
depthTest={true}
|
372 |
+
depthWrite={false}
|
373 |
+
/>
|
374 |
+
{#if children}
|
375 |
+
{@render children({ ref: mesh })}
|
376 |
+
{:else}
|
377 |
+
<T.PlaneGeometry args={typeof gridSize == "number" ? [gridSize, gridSize] : gridSize} />
|
378 |
+
{/if}
|
379 |
+
</T>
|
src/lib/components/3d/Robot.svelte
CHANGED
@@ -1,53 +1,506 @@
|
|
1 |
<script lang="ts">
|
2 |
-
import
|
3 |
-
import {
|
4 |
-
import
|
5 |
-
import
|
6 |
-
import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
7 |
|
8 |
interface Props {}
|
9 |
|
10 |
let {}: Props = $props();
|
11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
12 |
// Get all robots from the manager
|
13 |
const robots = $derived(robotManager.robots);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
14 |
</script>
|
15 |
|
16 |
{#each robots as robot, index (robot.id)}
|
17 |
-
{@const
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
23 |
>
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
52 |
</T.Group>
|
53 |
-
{/each}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
<script lang="ts">
|
2 |
+
import * as THREE from "three";
|
3 |
+
import { T, useThrelte } from "@threlte/core";
|
4 |
+
import { getRootLinks } from "@/components/3d/robot/URDF/utils/UrdfParser";
|
5 |
+
import UrdfLink from "@/components/3d/robot/URDF/primitives/UrdfLink.svelte";
|
6 |
+
import { robotManager } from "$lib/robot/RobotManager.svelte";
|
7 |
+
import { Billboard, HTML } from "@threlte/extras";
|
8 |
+
import { scale } from "svelte/transition";
|
9 |
+
import { onMount } from "svelte";
|
10 |
+
import { robotUrdfConfigMap } from "@/configs/robotUrdfConfig";
|
11 |
+
import MasterConnectionModal from "@/components/interface/overlay/MasterConnectionModal.svelte";
|
12 |
+
import SlaveConnectionModal from "@/components/interface/overlay/SlaveConnectionModal.svelte";
|
13 |
+
import ManualControlModal from "@/components/interface/overlay/ManualControlSheet.svelte";
|
14 |
+
import type { Robot } from "$lib/robot/Robot.svelte";
|
15 |
+
import Hoverable from "@/components/utils/Hoverable.svelte";
|
16 |
+
import { generateName } from "$lib/utils/generateName";
|
17 |
|
18 |
interface Props {}
|
19 |
|
20 |
let {}: Props = $props();
|
21 |
|
22 |
+
// Get camera controls
|
23 |
+
const { camera } = useThrelte();
|
24 |
+
|
25 |
+
// Modal state using runes
|
26 |
+
let isMasterModalOpen = $state(false);
|
27 |
+
let isSlaveModalOpen = $state(false);
|
28 |
+
let isManualControlModalOpen = $state(false);
|
29 |
+
let selectedRobot = $state<Robot | null>(null);
|
30 |
+
|
31 |
// Get all robots from the manager
|
32 |
const robots = $derived(robotManager.robots);
|
33 |
+
|
34 |
+
// Helper function to get connection status
|
35 |
+
function getConnectionStatus(robot: any) {
|
36 |
+
const status = robotManager.getRobotStatus(robot.id);
|
37 |
+
if (!status) return "offline";
|
38 |
+
|
39 |
+
if (status.hasActiveMaster && status.connectedSlaves > 0) {
|
40 |
+
return "active";
|
41 |
+
} else if (status.hasActiveMaster) {
|
42 |
+
return "Master Only";
|
43 |
+
} else if (status.connectedSlaves > 0) {
|
44 |
+
return "Slave Only";
|
45 |
+
}
|
46 |
+
return "idle";
|
47 |
+
}
|
48 |
+
|
49 |
+
// Helper function to get status color
|
50 |
+
function getStatusVariant(status: string) {
|
51 |
+
switch (status) {
|
52 |
+
case "Master + Slaves":
|
53 |
+
return "default"; // Green
|
54 |
+
case "Master Only":
|
55 |
+
return "secondary"; // Yellow
|
56 |
+
case "Slave Only":
|
57 |
+
return "outline"; // Blue
|
58 |
+
default:
|
59 |
+
return "destructive"; // Red/Gray
|
60 |
+
}
|
61 |
+
}
|
62 |
+
|
63 |
+
// Camera movement function
|
64 |
+
function moveCameraToRobot(robot: Robot, index: number) {
|
65 |
+
if (!camera.current) return;
|
66 |
+
|
67 |
+
// Calculate robot position (same logic as used in the template)
|
68 |
+
const gridWidth = 3;
|
69 |
+
const spacing = 6;
|
70 |
+
const totalRows = Math.ceil(robots.length / gridWidth);
|
71 |
+
const row = Math.floor(index / gridWidth);
|
72 |
+
const col = 1 + (index % gridWidth);
|
73 |
+
const xPosition = (col - Math.floor(gridWidth / 2)) * spacing;
|
74 |
+
const zPosition = (row - Math.floor(totalRows / 2)) * spacing;
|
75 |
+
|
76 |
+
// Camera positioning parameters - adjust these to change the rotation angle
|
77 |
+
const cameraDistance = 12; // Distance from robot
|
78 |
+
const cameraHeight = 8; // Height above ground
|
79 |
+
const angleOffset = Math.PI / 4; // 45 degrees - change this for different angles
|
80 |
+
|
81 |
+
// Calculate camera position using polar coordinates for easy angle control
|
82 |
+
const cameraX = xPosition + Math.cos(angleOffset) * cameraDistance;
|
83 |
+
const cameraZ = zPosition + Math.sin(angleOffset) * cameraDistance;
|
84 |
+
|
85 |
+
// Create target positions
|
86 |
+
const targetPosition = new THREE.Vector3(cameraX, cameraHeight, cameraZ);
|
87 |
+
const lookAtPosition = new THREE.Vector3(xPosition, 1, zPosition); // Look at robot center
|
88 |
+
|
89 |
+
// Animate camera movement
|
90 |
+
const startPosition = camera.current.position.clone();
|
91 |
+
const startTime = Date.now();
|
92 |
+
const duration = 1000; // 1 second animation
|
93 |
+
|
94 |
+
function animateCamera() {
|
95 |
+
const elapsed = Date.now() - startTime;
|
96 |
+
const progress = Math.min(elapsed / duration, 1);
|
97 |
+
|
98 |
+
// Use easing function for smooth animation
|
99 |
+
const easeProgress = 1 - Math.pow(1 - progress, 3); // easeOutCubic
|
100 |
+
|
101 |
+
// Interpolate position
|
102 |
+
camera.current.position.lerpVectors(startPosition, targetPosition, easeProgress);
|
103 |
+
camera.current.lookAt(lookAtPosition);
|
104 |
+
|
105 |
+
if (progress < 1) {
|
106 |
+
requestAnimationFrame(animateCamera);
|
107 |
+
}
|
108 |
+
}
|
109 |
+
|
110 |
+
animateCamera();
|
111 |
+
}
|
112 |
+
|
113 |
+
// Modal management functions
|
114 |
+
function openMasterModal(robot: Robot) {
|
115 |
+
console.log("Opening master modal for robot:", robot.id);
|
116 |
+
selectedRobot = robot;
|
117 |
+
isMasterModalOpen = true;
|
118 |
+
}
|
119 |
+
|
120 |
+
function openSlaveModal(robot: Robot) {
|
121 |
+
console.log("Opening slave modal for robot:", robot.id);
|
122 |
+
selectedRobot = robot;
|
123 |
+
isSlaveModalOpen = true;
|
124 |
+
}
|
125 |
+
|
126 |
+
function openManualControlModal(robot: Robot) {
|
127 |
+
console.log("Opening manual control modal for robot:", robot.id);
|
128 |
+
selectedRobot = robot;
|
129 |
+
isManualControlModalOpen = true;
|
130 |
+
}
|
131 |
+
|
132 |
+
// Handle stop propagation for nested buttons
|
133 |
+
function handleAddButtonClick(event: Event, robot: Robot, tab: "master" | "slaves") {
|
134 |
+
console.log("Handling add button click for robot:", robot.id, "tab:", tab);
|
135 |
+
event.stopPropagation();
|
136 |
+
if (tab === "master") {
|
137 |
+
openMasterModal(robot);
|
138 |
+
} else {
|
139 |
+
openSlaveModal(robot);
|
140 |
+
}
|
141 |
+
}
|
142 |
+
|
143 |
+
// Handle box clicks (using mousedown since it works reliably in 3D context)
|
144 |
+
function handleBoxClick(robot: Robot, type: "master" | "slaves" | "manual") {
|
145 |
+
console.log("Box clicked:", type, "for robot:", robot.id);
|
146 |
+
if (type === "master") {
|
147 |
+
openMasterModal(robot);
|
148 |
+
} else if (type === "slaves") {
|
149 |
+
openSlaveModal(robot);
|
150 |
+
} else if (type === "manual") {
|
151 |
+
openManualControlModal(robot);
|
152 |
+
}
|
153 |
+
}
|
154 |
+
|
155 |
+
onMount(() => {
|
156 |
+
function createRobot() {
|
157 |
+
const urdfConfig = robotUrdfConfigMap["so-arm100"];
|
158 |
+
|
159 |
+
if (!urdfConfig) {
|
160 |
+
return;
|
161 |
+
}
|
162 |
+
|
163 |
+
const robotId = generateName();
|
164 |
+
console.log("Creating robot with ID:", robotId, "and config:", urdfConfig);
|
165 |
+
|
166 |
+
robotManager.createRobot(robotId, urdfConfig);
|
167 |
+
}
|
168 |
+
|
169 |
+
// If no robot then create one
|
170 |
+
if (robots.length === 0) {
|
171 |
+
createRobot();
|
172 |
+
}
|
173 |
+
});
|
174 |
+
|
175 |
+
function generateRobotName() {
|
176 |
+
throw new Error("Function not implemented.");
|
177 |
+
}
|
178 |
</script>
|
179 |
|
180 |
{#each robots as robot, index (robot.id)}
|
181 |
+
{@const gridWidth = 3}
|
182 |
+
<!-- Number of robots per row -->
|
183 |
+
{@const spacing = 6}
|
184 |
+
<!-- Space between robots -->
|
185 |
+
{@const totalRows = Math.ceil(robots.length / gridWidth)}
|
186 |
+
{@const row = Math.floor(index / gridWidth)}
|
187 |
+
{@const col = 1 + (index % gridWidth)}
|
188 |
+
{@const xPosition = (col - Math.floor(gridWidth / 2)) * spacing}
|
189 |
+
<!-- Center the grid on x-axis -->
|
190 |
+
{@const zPosition = (row - Math.floor(totalRows / 2)) * spacing}
|
191 |
+
<!-- Center the grid on z-axis -->
|
192 |
+
{@const robotStatus = robotManager.getRobotStatus(robot.id)}
|
193 |
+
{@const connectionStatus = getConnectionStatus(robot)}
|
194 |
+
{@const statusVariant = getStatusVariant(connectionStatus)}
|
195 |
+
<T.Group
|
196 |
+
position.x={xPosition}
|
197 |
+
position.y={0}
|
198 |
+
position.z={zPosition}
|
199 |
+
quaternion={[0, 0, 0, 1]}
|
200 |
+
scale={[10, 10, 10]}
|
201 |
+
rotation={[-Math.PI / 2, 0, 0]}
|
202 |
+
>
|
203 |
+
<Hoverable
|
204 |
+
onClick={() => {
|
205 |
+
moveCameraToRobot(robot, index);
|
206 |
+
handleBoxClick(robot, "manual");
|
207 |
+
}}
|
208 |
>
|
209 |
+
{#snippet content({ isHovered, isSelected })}
|
210 |
+
{#each getRootLinks(robot.robotState.robot) as link}
|
211 |
+
<UrdfLink
|
212 |
+
robot={robot.robotState.robot}
|
213 |
+
{link}
|
214 |
+
textScale={0.2}
|
215 |
+
showName={isHovered || isSelected}
|
216 |
+
showVisual={true}
|
217 |
+
showCollision={false}
|
218 |
+
visualColor="#333333"
|
219 |
+
visualOpacity={isHovered || isSelected ? 0.4 : 1.0}
|
220 |
+
collisionOpacity={1.0}
|
221 |
+
collisionColor="#813d9c"
|
222 |
+
jointNames={isHovered || isSelected}
|
223 |
+
joints={isHovered || isSelected}
|
224 |
+
jointColor="#62a0ea"
|
225 |
+
jointIndicatorColor="#f66151"
|
226 |
+
nameHeight={0.1}
|
227 |
+
selectedLink={robot.robotState.selection.selectedLink}
|
228 |
+
selectedJoint={robot.robotState.selection.selectedJoint}
|
229 |
+
highlightColor="#ffa348"
|
230 |
+
showLine={isHovered || isSelected}
|
231 |
+
opacity={1}
|
232 |
+
isInteractive={false}
|
233 |
+
/>
|
234 |
+
{/each}
|
235 |
+
|
236 |
+
<T.Group position.z={0.25} rotation={[Math.PI / 2, 0, 0]} scale={[0.12, 0.12, 0.12]}>
|
237 |
+
<Billboard>
|
238 |
+
<HTML
|
239 |
+
transform
|
240 |
+
autoRender={true}
|
241 |
+
center={true}
|
242 |
+
distanceFactor={3}
|
243 |
+
pointerEvents="auto"
|
244 |
+
style="
|
245 |
+
pointer-events: auto !important;
|
246 |
+
image-rendering: auto;
|
247 |
+
image-rendering: smooth;
|
248 |
+
text-rendering: optimizeLegibility;
|
249 |
+
-webkit-font-smoothing: subpixel-antialiased;
|
250 |
+
-moz-osx-font-smoothing: auto;
|
251 |
+
backface-visibility: hidden;
|
252 |
+
transform-style: preserve-3d;
|
253 |
+
will-change: transform;
|
254 |
+
"
|
255 |
+
>
|
256 |
+
{#if isHovered || isSelected}
|
257 |
+
<div
|
258 |
+
class="pointer-events-auto select-none"
|
259 |
+
style="pointer-events: auto !important;"
|
260 |
+
in:scale={{ duration: 200, start: 0.5 }}
|
261 |
+
>
|
262 |
+
<div class="flex items-center gap-3">
|
263 |
+
<!-- Manual Control Box -->
|
264 |
+
<button
|
265 |
+
class={[
|
266 |
+
"relative min-h-[80px] min-w-[90px] cursor-pointer rounded-lg border border-purple-500/40 bg-purple-950/20 bg-slate-900/80 px-3 py-3 backdrop-blur-sm transition-colors hover:border-purple-400/60",
|
267 |
+
robotStatus?.hasActiveMaster
|
268 |
+
? "cursor-not-allowed border-dashed opacity-40"
|
269 |
+
: robot.manualControlEnabled
|
270 |
+
? ""
|
271 |
+
: "border-dashed opacity-60"
|
272 |
+
]}
|
273 |
+
style="pointer-events: auto !important;"
|
274 |
+
onmousedown={() =>
|
275 |
+
!robotStatus?.hasActiveMaster && handleBoxClick(robot, "manual")}
|
276 |
+
onclick={() =>
|
277 |
+
!robotStatus?.hasActiveMaster && handleBoxClick(robot, "manual")}
|
278 |
+
disabled={robotStatus?.hasActiveMaster}
|
279 |
+
>
|
280 |
+
{#if robotStatus?.hasActiveMaster}
|
281 |
+
<div class="flex flex-col items-center">
|
282 |
+
<div class="flex items-center gap-1">
|
283 |
+
<span class="icon-[mdi--lock] size-3 text-purple-400/60"></span>
|
284 |
+
<span
|
285 |
+
class="text-xs leading-tight font-medium text-purple-400/60 uppercase"
|
286 |
+
>CONTROL DISABLED</span
|
287 |
+
>
|
288 |
+
</div>
|
289 |
+
<span class="mt-0.5 text-center text-xs leading-tight text-purple-300/60">
|
290 |
+
Control managed by
|
291 |
+
</span>
|
292 |
+
<span
|
293 |
+
class="text-center text-xs leading-tight font-semibold text-purple-300/60"
|
294 |
+
>
|
295 |
+
{robot.master?.name?.slice(0, 20) || "Master"}
|
296 |
+
</span>
|
297 |
+
</div>
|
298 |
+
{:else if robot.manualControlEnabled}
|
299 |
+
<div class="flex flex-col items-center">
|
300 |
+
<div class="flex items-center gap-1">
|
301 |
+
<span class="icon-[mdi--tune] size-3 text-purple-400"></span>
|
302 |
+
<span
|
303 |
+
class="text-xs leading-tight font-semibold text-purple-400 uppercase"
|
304 |
+
>MANUAL</span
|
305 |
+
>
|
306 |
+
</div>
|
307 |
+
<span class="mt-0.5 text-xs leading-tight text-purple-200">
|
308 |
+
{robot.activeJoints.length} Joints Active
|
309 |
+
</span>
|
310 |
+
<span class="text-xs leading-tight text-purple-300/80">
|
311 |
+
Click to take manual control
|
312 |
+
</span>
|
313 |
+
</div>
|
314 |
+
{:else}
|
315 |
+
<div class="flex flex-col items-center">
|
316 |
+
<div class="flex items-center gap-1">
|
317 |
+
<span class="icon-[mdi--tune] size-3 text-purple-400/60"></span>
|
318 |
+
<span class="text-xs leading-tight font-medium text-purple-400/60"
|
319 |
+
>MANUAL OFF</span
|
320 |
+
>
|
321 |
+
</div>
|
322 |
+
<span class="mt-0.5 text-xs leading-tight text-purple-300/40"
|
323 |
+
>Click to Configure</span
|
324 |
+
>
|
325 |
+
<div class="mt-2 text-purple-400/60">
|
326 |
+
<span class="icon-[mdi--cog-outline] size-4"></span>
|
327 |
+
</div>
|
328 |
+
</div>
|
329 |
+
{/if}
|
330 |
+
</button>
|
331 |
+
</div>
|
332 |
+
</div>
|
333 |
+
{:else}
|
334 |
+
<div
|
335 |
+
class="pointer-events-auto select-none"
|
336 |
+
style="pointer-events: auto !important;"
|
337 |
+
in:scale={{ duration: 200, start: 0.5 }}
|
338 |
+
>
|
339 |
+
<div class="flex items-center gap-3">
|
340 |
+
<!-- Master Box -->
|
341 |
+
<button
|
342 |
+
class={[
|
343 |
+
"relative min-h-[80px] min-w-[90px] cursor-pointer rounded-lg border border-green-500/40 bg-green-950/20 bg-slate-900/80 px-3 py-3 backdrop-blur-sm transition-colors hover:border-green-400/60",
|
344 |
+
robotStatus?.hasActiveMaster ? "" : "border-dashed opacity-60"
|
345 |
+
]}
|
346 |
+
style="pointer-events: auto !important;"
|
347 |
+
onmousedown={() => handleBoxClick(robot, "master")}
|
348 |
+
onclick={() => handleBoxClick(robot, "master")}
|
349 |
+
>
|
350 |
+
{#if robotStatus?.hasActiveMaster}
|
351 |
+
<div class="flex flex-col items-center">
|
352 |
+
<div class="flex items-center gap-1">
|
353 |
+
<span class="icon-[mdi--speak] size-3 text-green-400"></span>
|
354 |
+
<span
|
355 |
+
class="text-xs leading-tight font-semibold text-green-400 uppercase"
|
356 |
+
>MASTER</span
|
357 |
+
>
|
358 |
+
</div>
|
359 |
+
<span class="mt-0.5 text-xs leading-tight text-green-200">
|
360 |
+
{robot.master?.name.slice(0, 30) || "Unknown"}
|
361 |
+
</span>
|
362 |
+
{#if robot.master?.constructor.name}
|
363 |
+
<span class="text-xs leading-tight text-green-300/80">
|
364 |
+
{robot.master?.constructor.name.replace("Driver", "").slice(0, 30)}
|
365 |
+
</span>
|
366 |
+
{:else}
|
367 |
+
<span class="text-xs leading-tight text-red-300/80"> N/A </span>
|
368 |
+
{/if}
|
369 |
+
<div
|
370 |
+
class="mt-1 h-1.5 w-1.5 animate-pulse rounded-full bg-green-400"
|
371 |
+
></div>
|
372 |
+
</div>
|
373 |
+
{:else}
|
374 |
+
<div class="flex flex-col items-center">
|
375 |
+
<div class="flex items-center gap-1">
|
376 |
+
<span class="icon-[mdi--speak] size-3 text-green-400/60"></span>
|
377 |
+
<span class="text-xs leading-tight font-medium text-green-400/60"
|
378 |
+
>NO MASTER</span
|
379 |
+
>
|
380 |
+
</div>
|
381 |
+
<span class="mt-0.5 text-xs leading-tight text-green-300/40"
|
382 |
+
>Click to Connect</span
|
383 |
+
>
|
384 |
+
<div class="mt-2 text-green-400/60">
|
385 |
+
<span class="icon-[mdi--plus-circle] size-4"></span>
|
386 |
+
</div>
|
387 |
+
</div>
|
388 |
+
{/if}
|
389 |
+
</button>
|
390 |
+
|
391 |
+
<!-- Arrow 1: Master to Robot -->
|
392 |
+
<div class="font-mono text-sm text-slate-400">
|
393 |
+
{#if robotStatus?.hasActiveMaster}
|
394 |
+
<span class="text-green-400">→</span>
|
395 |
+
{:else}
|
396 |
+
<span class="text-green-400/50">⇢</span>
|
397 |
+
{/if}
|
398 |
+
</div>
|
399 |
+
|
400 |
+
<!-- Robot Box (Simplified) -->
|
401 |
+
<div
|
402 |
+
class={[
|
403 |
+
"min-h-[80px] min-w-[90px] rounded-lg border border-amber-500/40 bg-slate-900/80 px-3 py-3 backdrop-blur-sm"
|
404 |
+
]}
|
405 |
+
>
|
406 |
+
<div class="flex flex-col items-center">
|
407 |
+
<div class="flex items-center gap-1">
|
408 |
+
<span class="icon-[mdi--connection] size-3 text-amber-400"></span>
|
409 |
+
<span
|
410 |
+
class="text-xs leading-tight font-semibold text-amber-400 uppercase"
|
411 |
+
>
|
412 |
+
Robot
|
413 |
+
</span>
|
414 |
+
</div>
|
415 |
+
<span class="mt-0.5 text-xs leading-tight text-amber-200">
|
416 |
+
{robot.id}
|
417 |
+
</span>
|
418 |
+
</div>
|
419 |
+
</div>
|
420 |
+
|
421 |
+
<!-- Arrow 2: Robot to Slaves -->
|
422 |
+
<div class="font-mono text-sm text-slate-400">
|
423 |
+
{#if robotStatus?.connectedSlaves && robotStatus.connectedSlaves > 0}
|
424 |
+
<span class="text-blue-400">→</span>
|
425 |
+
{:else}
|
426 |
+
<span class="text-blue-400/50">⇢</span>
|
427 |
+
{/if}
|
428 |
+
</div>
|
429 |
+
|
430 |
+
<!-- Slaves Box -->
|
431 |
+
<button
|
432 |
+
class={[
|
433 |
+
"relative min-h-[80px] min-w-[90px] cursor-pointer rounded-lg border border-blue-500/40 bg-blue-950/20 bg-slate-900/80 px-3 py-3 backdrop-blur-sm transition-colors hover:border-blue-400/60",
|
434 |
+
robotStatus?.connectedSlaves && robotStatus.connectedSlaves > 0
|
435 |
+
? ""
|
436 |
+
: "border-dashed opacity-60"
|
437 |
+
]}
|
438 |
+
style="pointer-events: auto !important;"
|
439 |
+
onmousedown={() => handleBoxClick(robot, "slaves")}
|
440 |
+
onclick={() => handleBoxClick(robot, "slaves")}
|
441 |
+
>
|
442 |
+
{#if robotStatus?.connectedSlaves && robotStatus.connectedSlaves > 0}
|
443 |
+
<div class="flex flex-col items-center">
|
444 |
+
<div class="flex items-center gap-1">
|
445 |
+
<span class="icon-[fa6-solid--ear-listen] size-3 text-blue-400"></span>
|
446 |
+
<span
|
447 |
+
class="text-xs leading-tight font-semibold text-blue-400 uppercase"
|
448 |
+
>SLAVES</span
|
449 |
+
>
|
450 |
+
</div>
|
451 |
+
{#if robot.slaves.length > 0}
|
452 |
+
<div class="mt-1 flex flex-col items-center gap-0.5">
|
453 |
+
{#each robot.slaves.slice(0, 2) as slave}
|
454 |
+
<div class="mt-0.5 text-xs leading-tight text-blue-300/80">
|
455 |
+
{slave.name.slice(0, 30) || "Slave"}
|
456 |
+
</div>
|
457 |
+
<div class="text-xs leading-tight text-blue-200">
|
458 |
+
{slave.constructor.name.replace("Driver", "").slice(0, 30) ||
|
459 |
+
"N/A"}
|
460 |
+
</div>
|
461 |
+
{/each}
|
462 |
+
{#if robot.slaves.length > 2}
|
463 |
+
<span class="text-xs text-blue-400/60"
|
464 |
+
>+{robot.slaves.length - 2} more</span
|
465 |
+
>
|
466 |
+
{/if}
|
467 |
+
</div>
|
468 |
+
{/if}
|
469 |
+
</div>
|
470 |
+
{:else}
|
471 |
+
<div class="flex flex-col items-center">
|
472 |
+
<div class="flex items-center gap-1">
|
473 |
+
<span class="icon-[fa6-solid--ear-listen] size-3 text-blue-400/60"
|
474 |
+
></span>
|
475 |
+
<span class="text-xs leading-tight font-medium text-blue-400/60"
|
476 |
+
>NO SLAVES</span
|
477 |
+
>
|
478 |
+
</div>
|
479 |
+
<span class="mt-0.5 text-xs leading-tight text-blue-300/40"
|
480 |
+
>Click to Connect</span
|
481 |
+
>
|
482 |
+
<div class="mt-2 text-blue-400/60">
|
483 |
+
<span class="icon-[mdi--plus-circle] size-4"></span>
|
484 |
+
</div>
|
485 |
+
</div>
|
486 |
+
{/if}
|
487 |
+
</button>
|
488 |
+
</div>
|
489 |
+
</div>
|
490 |
+
{/if}
|
491 |
+
</HTML>
|
492 |
+
</Billboard>
|
493 |
+
</T.Group>
|
494 |
+
{/snippet}
|
495 |
+
</Hoverable>
|
496 |
</T.Group>
|
497 |
+
{/each}
|
498 |
+
|
499 |
+
<!-- Master Connection Modal -->
|
500 |
+
<MasterConnectionModal bind:open={isMasterModalOpen} robot={selectedRobot} />
|
501 |
+
|
502 |
+
<!-- Slave Connection Modal -->
|
503 |
+
<SlaveConnectionModal bind:open={isSlaveModalOpen} robot={selectedRobot} />
|
504 |
+
|
505 |
+
<!-- Manual Control Modal -->
|
506 |
+
<ManualControlModal bind:open={isManualControlModalOpen} robot={selectedRobot} />
|
src/lib/components/3d/robot/URDF/createRobot.svelte.ts
CHANGED
@@ -3,25 +3,25 @@ import type { RobotUrdfConfig } from "$lib/types/urdf";
|
|
3 |
import { UrdfParser } from "./utils/UrdfParser";
|
4 |
|
5 |
export async function createRobot(urdfConfig: RobotUrdfConfig): Promise<RobotState> {
|
6 |
-
|
7 |
-
|
8 |
-
|
9 |
|
10 |
-
|
11 |
-
|
12 |
-
|
13 |
-
// Make the selection state reactive as well
|
14 |
-
const reactiveSelection = $state({
|
15 |
-
isSelected: false,
|
16 |
-
selectedLink: undefined,
|
17 |
-
selectedJoint: undefined
|
18 |
-
});
|
19 |
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
|
|
|
25 |
|
26 |
-
|
27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
3 |
import { UrdfParser } from "./utils/UrdfParser";
|
4 |
|
5 |
export async function createRobot(urdfConfig: RobotUrdfConfig): Promise<RobotState> {
|
6 |
+
const customParser = new UrdfParser(urdfConfig.urdfUrl, "/robots/so-100/");
|
7 |
+
const urdfData = await customParser.load();
|
8 |
+
const robot = customParser.fromString(urdfData);
|
9 |
|
10 |
+
// Make the robot data reactive so mutations to joint.rotation trigger reactivity
|
11 |
+
const reactiveRobot = $state(robot);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
12 |
|
13 |
+
// Make the selection state reactive as well
|
14 |
+
const reactiveSelection = $state({
|
15 |
+
isSelected: false,
|
16 |
+
selectedLink: undefined,
|
17 |
+
selectedJoint: undefined
|
18 |
+
});
|
19 |
|
20 |
+
const robotState: RobotState = {
|
21 |
+
robot: reactiveRobot,
|
22 |
+
urdfConfig: urdfConfig,
|
23 |
+
selection: reactiveSelection
|
24 |
+
};
|
25 |
+
|
26 |
+
return robotState;
|
27 |
+
}
|
src/lib/components/3d/robot/URDF/interfaces/IUrdfBox.ts
CHANGED
@@ -1,3 +1,3 @@
|
|
1 |
export default interface IUrdfBox {
|
2 |
-
|
3 |
-
|
|
|
1 |
export default interface IUrdfBox {
|
2 |
+
size: [x: number, y: number, z: number];
|
3 |
+
}
|
src/lib/components/3d/robot/URDF/interfaces/IUrdfCylinder.ts
CHANGED
@@ -1,4 +1,4 @@
|
|
1 |
export default interface IUrdfCylinder {
|
2 |
-
|
3 |
-
|
4 |
-
|
|
|
1 |
export default interface IUrdfCylinder {
|
2 |
+
radius: number;
|
3 |
+
length: number;
|
4 |
+
}
|
src/lib/components/3d/robot/URDF/interfaces/IUrdfJoint.ts
CHANGED
@@ -1,42 +1,42 @@
|
|
1 |
-
import type IUrdfLink from "./IUrdfLink"
|
2 |
|
3 |
export default interface IUrdfJoint {
|
4 |
-
|
5 |
-
|
6 |
-
|
7 |
-
|
8 |
-
|
9 |
-
|
10 |
-
|
11 |
-
|
12 |
-
|
13 |
-
|
14 |
-
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
}
|
|
|
1 |
+
import type IUrdfLink from "./IUrdfLink";
|
2 |
|
3 |
export default interface IUrdfJoint {
|
4 |
+
name?: string;
|
5 |
+
type?: "revolute" | "continuous" | "prismatic" | "fixed" | "floating" | "planar";
|
6 |
+
// rpy = roll, pitch, yaw (values between -pi and +pi)
|
7 |
+
origin_rpy: [roll: number, pitch: number, yaw: number];
|
8 |
+
origin_xyz: [x: number, y: number, z: number];
|
9 |
+
// calculated rotation for non-fixed joints based on origin_rpy and axis_xyz
|
10 |
+
rotation: [x: number, y: number, z: number];
|
11 |
+
parent: IUrdfLink;
|
12 |
+
child: IUrdfLink;
|
13 |
+
// axis for revolute and continuous joints defaults to (1,0,0)
|
14 |
+
axis_xyz?: [x: number, y: number, z: number];
|
15 |
+
calibration?: {
|
16 |
+
rising?: number; // Calibration rising value in radians
|
17 |
+
falling?: number; // Calibration falling value in radians
|
18 |
+
};
|
19 |
+
dynamics?: {
|
20 |
+
damping?: number;
|
21 |
+
friction?: number;
|
22 |
+
};
|
23 |
+
// only for revolute joints
|
24 |
+
limit?: {
|
25 |
+
lower?: number;
|
26 |
+
upper?: number;
|
27 |
+
effort: number;
|
28 |
+
velocity: number;
|
29 |
+
};
|
30 |
+
mimic?: {
|
31 |
+
joint: string;
|
32 |
+
multiplier?: number;
|
33 |
+
offset?: number;
|
34 |
+
};
|
35 |
+
safety_controller?: {
|
36 |
+
soft_lower_limit?: number;
|
37 |
+
soft_upper_limit?: number;
|
38 |
+
k_position?: number;
|
39 |
+
k_velocity: number;
|
40 |
+
};
|
41 |
+
elem: Element;
|
42 |
+
}
|
src/lib/components/3d/robot/URDF/interfaces/IUrdfLink.ts
CHANGED
@@ -1,23 +1,23 @@
|
|
1 |
-
import type {IUrdfVisual} from "./IUrdfVisual"
|
2 |
|
3 |
interface IUrdfInertia {
|
4 |
-
|
5 |
-
|
6 |
-
|
7 |
-
|
8 |
-
|
9 |
-
|
10 |
}
|
11 |
|
12 |
export default interface IUrdfLink {
|
13 |
-
|
14 |
-
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
}
|
|
|
1 |
+
import type { IUrdfVisual } from "./IUrdfVisual";
|
2 |
|
3 |
interface IUrdfInertia {
|
4 |
+
ixx: number;
|
5 |
+
ixy: number;
|
6 |
+
ixz: number;
|
7 |
+
iyy: number;
|
8 |
+
iyz: number;
|
9 |
+
izz: number;
|
10 |
}
|
11 |
|
12 |
export default interface IUrdfLink {
|
13 |
+
name: string;
|
14 |
+
inertial?: {
|
15 |
+
origin_xyz?: [x: number, y: number, z: number];
|
16 |
+
origin_rpy?: [roll: number, pitch: number, yaw: number];
|
17 |
+
mass: number;
|
18 |
+
inertia: IUrdfInertia;
|
19 |
+
};
|
20 |
+
visual: IUrdfVisual[];
|
21 |
+
collision: IUrdfVisual[];
|
22 |
+
elem: Element;
|
23 |
+
}
|
src/lib/components/3d/robot/URDF/interfaces/IUrdfMesh.ts
CHANGED
@@ -1,5 +1,5 @@
|
|
1 |
export default interface IUrdfMesh {
|
2 |
-
|
3 |
-
|
4 |
-
|
5 |
-
|
|
|
1 |
export default interface IUrdfMesh {
|
2 |
+
filename: string;
|
3 |
+
type: "stl" | "fbx" | "obj" | "dae";
|
4 |
+
scale: [x: number, y: number, z: number];
|
5 |
+
}
|
src/lib/components/3d/robot/URDF/interfaces/IUrdfRobot.ts
CHANGED
@@ -1,10 +1,10 @@
|
|
1 |
-
import type IUrdfJoint from "./IUrdfJoint"
|
2 |
-
import type IUrdfLink from "./IUrdfLink"
|
3 |
|
4 |
export default interface IUrdfRobot {
|
5 |
-
|
6 |
-
|
7 |
-
|
8 |
-
|
9 |
-
|
10 |
-
}
|
|
|
1 |
+
import type IUrdfJoint from "./IUrdfJoint";
|
2 |
+
import type IUrdfLink from "./IUrdfLink";
|
3 |
|
4 |
export default interface IUrdfRobot {
|
5 |
+
name: string;
|
6 |
+
links: { [name: string]: IUrdfLink };
|
7 |
+
joints: IUrdfJoint[];
|
8 |
+
// the DOM element holding the XML, so we can work non-destructive
|
9 |
+
elem?: Element;
|
10 |
+
}
|
src/lib/components/3d/robot/URDF/interfaces/IUrdfVisual.ts
CHANGED
@@ -4,52 +4,52 @@ import type IUrdfMesh from "./IUrdfMesh";
|
|
4 |
|
5 |
// 1) Box‐type visual
|
6 |
interface IUrdfVisualBox {
|
7 |
-
|
8 |
-
|
9 |
-
|
10 |
-
|
11 |
-
|
12 |
-
|
13 |
-
|
14 |
-
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
}
|
22 |
|
23 |
// 2) Cylinder‐type visual
|
24 |
interface IUrdfVisualCylinder {
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
}
|
38 |
|
39 |
// 3) Mesh‐type visual
|
40 |
interface IUrdfVisualMesh {
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
}
|
54 |
|
55 |
// Now make a union of the three:
|
|
|
4 |
|
5 |
// 1) Box‐type visual
|
6 |
interface IUrdfVisualBox {
|
7 |
+
name: string;
|
8 |
+
origin_xyz: [x: number, y: number, z: number];
|
9 |
+
origin_rpy: [roll: number, pitch: number, yaw: number];
|
10 |
+
geometry: IUrdfBox;
|
11 |
+
material?: {
|
12 |
+
name: string;
|
13 |
+
color?: string;
|
14 |
+
texture?: string;
|
15 |
+
};
|
16 |
+
type: "box";
|
17 |
+
// optional RGBA color override
|
18 |
+
color_rgba?: [r: number, g: number, b: number, a: number];
|
19 |
+
// XML Element reference
|
20 |
+
elem: Element;
|
21 |
}
|
22 |
|
23 |
// 2) Cylinder‐type visual
|
24 |
interface IUrdfVisualCylinder {
|
25 |
+
name: string;
|
26 |
+
origin_xyz: [x: number, y: number, z: number];
|
27 |
+
origin_rpy: [roll: number, pitch: number, yaw: number];
|
28 |
+
geometry: IUrdfCylinder;
|
29 |
+
material?: {
|
30 |
+
name: string;
|
31 |
+
color?: string;
|
32 |
+
texture?: string;
|
33 |
+
};
|
34 |
+
type: "cylinder";
|
35 |
+
color_rgba?: [r: number, g: number, b: number, a: number];
|
36 |
+
elem: Element;
|
37 |
}
|
38 |
|
39 |
// 3) Mesh‐type visual
|
40 |
interface IUrdfVisualMesh {
|
41 |
+
name: string;
|
42 |
+
origin_xyz: [x: number, y: number, z: number];
|
43 |
+
origin_rpy: [roll: number, pitch: number, yaw: number];
|
44 |
+
geometry: IUrdfMesh;
|
45 |
+
material?: {
|
46 |
+
name: string;
|
47 |
+
color?: string;
|
48 |
+
texture?: string;
|
49 |
+
};
|
50 |
+
type: "mesh";
|
51 |
+
color_rgba?: [r: number, g: number, b: number, a: number];
|
52 |
+
elem: Element;
|
53 |
}
|
54 |
|
55 |
// Now make a union of the three:
|
src/lib/components/3d/robot/URDF/interfaces/index.ts
CHANGED
@@ -1,7 +1,7 @@
|
|
1 |
-
export * from
|
2 |
-
export * from
|
3 |
-
export * from
|
4 |
-
export * from
|
5 |
-
export * from
|
6 |
-
export * from
|
7 |
-
export * from
|
|
|
1 |
+
export * from "./IUrdfBox";
|
2 |
+
export * from "./IUrdfCylinder";
|
3 |
+
export * from "./IUrdfJoint";
|
4 |
+
export * from "./IUrdfLink";
|
5 |
+
export * from "./IUrdfMesh";
|
6 |
+
export * from "./IUrdfRobot";
|
7 |
+
export * from "./IUrdfVisual";
|
src/lib/components/3d/robot/URDF/mesh/DAE.svelte
CHANGED
@@ -1,8 +1,8 @@
|
|
1 |
<script lang="ts">
|
2 |
-
import { T, useLoader } from
|
3 |
-
import type { InteractivityProps } from
|
4 |
-
import { DoubleSide, Mesh, type Side } from
|
5 |
-
import { ColladaLoader } from
|
6 |
|
7 |
type Props = InteractivityProps & {
|
8 |
filename: string;
|
@@ -19,7 +19,7 @@
|
|
19 |
};
|
20 |
let {
|
21 |
filename,
|
22 |
-
color =
|
23 |
scale = [1, 1, 1],
|
24 |
position = [0, 0, 0],
|
25 |
rotation = [0, 0, 0],
|
@@ -47,7 +47,7 @@
|
|
47 |
rotation={dae.scene.rotation ? dae.scene.rotation.toArray() : [0, 0, 0]}
|
48 |
>
|
49 |
{#each dae.scene.children as obj}
|
50 |
-
{#if obj.type ===
|
51 |
{@const mesh = obj as Mesh}
|
52 |
<T.Mesh
|
53 |
{castShadow}
|
|
|
1 |
<script lang="ts">
|
2 |
+
import { T, useLoader } from "@threlte/core";
|
3 |
+
import type { InteractivityProps } from "@threlte/extras";
|
4 |
+
import { DoubleSide, Mesh, type Side } from "three";
|
5 |
+
import { ColladaLoader } from "three/examples/jsm/loaders/ColladaLoader.js";
|
6 |
|
7 |
type Props = InteractivityProps & {
|
8 |
filename: string;
|
|
|
19 |
};
|
20 |
let {
|
21 |
filename,
|
22 |
+
color = "#ffffff",
|
23 |
scale = [1, 1, 1],
|
24 |
position = [0, 0, 0],
|
25 |
rotation = [0, 0, 0],
|
|
|
47 |
rotation={dae.scene.rotation ? dae.scene.rotation.toArray() : [0, 0, 0]}
|
48 |
>
|
49 |
{#each dae.scene.children as obj}
|
50 |
+
{#if obj.type === "Mesh"}
|
51 |
{@const mesh = obj as Mesh}
|
52 |
<T.Mesh
|
53 |
{castShadow}
|
src/lib/components/3d/robot/URDF/mesh/OBJ.svelte
CHANGED
@@ -1,8 +1,8 @@
|
|
1 |
<script lang="ts">
|
2 |
-
import { T, useLoader } from
|
3 |
-
import type { InteractivityProps } from
|
4 |
-
import { DoubleSide, type Side } from
|
5 |
-
import { OBJLoader } from
|
6 |
|
7 |
type Props = InteractivityProps & {
|
8 |
filename: string;
|
@@ -19,7 +19,7 @@
|
|
19 |
};
|
20 |
let {
|
21 |
filename,
|
22 |
-
color =
|
23 |
scale = [1, 1, 1],
|
24 |
rotation = [0, 0, 0],
|
25 |
position = [0, 0, 0],
|
|
|
1 |
<script lang="ts">
|
2 |
+
import { T, useLoader } from "@threlte/core";
|
3 |
+
import type { InteractivityProps } from "@threlte/extras";
|
4 |
+
import { DoubleSide, type Side } from "three";
|
5 |
+
import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js";
|
6 |
|
7 |
type Props = InteractivityProps & {
|
8 |
filename: string;
|
|
|
19 |
};
|
20 |
let {
|
21 |
filename,
|
22 |
+
color = "#ffffff",
|
23 |
scale = [1, 1, 1],
|
24 |
rotation = [0, 0, 0],
|
25 |
position = [0, 0, 0],
|
src/lib/components/3d/robot/URDF/mesh/STL.svelte
CHANGED
@@ -1,9 +1,9 @@
|
|
1 |
<script lang="ts">
|
2 |
-
import { T, useLoader } from
|
3 |
-
import type { InteractivityProps } from
|
4 |
-
import { STLLoader } from
|
5 |
-
import { type Side, DoubleSide } from
|
6 |
-
import { interactivity } from
|
7 |
|
8 |
type Props = InteractivityProps & {
|
9 |
filename: string;
|
@@ -21,7 +21,7 @@
|
|
21 |
|
22 |
let {
|
23 |
filename,
|
24 |
-
color =
|
25 |
scale = [1, 1, 1],
|
26 |
position = [0, 0, 0],
|
27 |
rotation = [0, 0, 0],
|
@@ -44,7 +44,7 @@
|
|
44 |
{#await load(filename) then stl}
|
45 |
{@html `<!-- include stl: ${filename} ${scale} -->`}
|
46 |
<T.Mesh geometry={stl} {scale} {castShadow} {receiveShadow} {position} {rotation} {...restProps}>
|
47 |
-
<T.MeshLambertMaterial
|
48 |
</T.Mesh>
|
49 |
{/await}
|
50 |
|
|
|
1 |
<script lang="ts">
|
2 |
+
import { T, useLoader } from "@threlte/core";
|
3 |
+
import type { InteractivityProps } from "@threlte/extras";
|
4 |
+
import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js";
|
5 |
+
import { type Side, DoubleSide } from "three";
|
6 |
+
import { interactivity } from "@threlte/extras";
|
7 |
|
8 |
type Props = InteractivityProps & {
|
9 |
filename: string;
|
|
|
21 |
|
22 |
let {
|
23 |
filename,
|
24 |
+
color = "#ffffff",
|
25 |
scale = [1, 1, 1],
|
26 |
position = [0, 0, 0],
|
27 |
rotation = [0, 0, 0],
|
|
|
44 |
{#await load(filename) then stl}
|
45 |
{@html `<!-- include stl: ${filename} ${scale} -->`}
|
46 |
<T.Mesh geometry={stl} {scale} {castShadow} {receiveShadow} {position} {rotation} {...restProps}>
|
47 |
+
<T.MeshLambertMaterial {color} {opacity} transparent={opacity < 1.0} {wireframe} {side} />
|
48 |
</T.Mesh>
|
49 |
{/await}
|
50 |
|
src/lib/components/3d/robot/URDF/primitives/UrdfJoint.svelte
CHANGED
@@ -1,20 +1,21 @@
|
|
1 |
<script lang="ts">
|
2 |
-
import type IUrdfJoint from
|
3 |
-
import { T } from
|
4 |
-
import UrdfLink from
|
5 |
-
import { Vector3 } from
|
6 |
import {
|
7 |
Billboard,
|
8 |
interactivity,
|
9 |
MeshLineGeometry,
|
10 |
Text,
|
11 |
type InteractivityProps
|
12 |
-
} from
|
13 |
|
14 |
-
import type IUrdfLink from
|
15 |
-
import type IUrdfRobot from
|
|
|
16 |
|
17 |
-
const defaultOnClick: InteractivityProps[
|
18 |
event.stopPropagation();
|
19 |
selectedLink = undefined;
|
20 |
selectedJoint = joint;
|
@@ -49,9 +50,9 @@
|
|
49 |
selectedLink = $bindable(),
|
50 |
showLine = false,
|
51 |
nameHeight = 0.02,
|
52 |
-
highlightColor =
|
53 |
-
jointColor =
|
54 |
-
jointIndicatorColor =
|
55 |
opacity = 0.7,
|
56 |
isInteractive = false,
|
57 |
showName = false,
|
@@ -59,7 +60,7 @@
|
|
59 |
showCollision = true,
|
60 |
visualOpacity = 1,
|
61 |
collisionOpacity = 1,
|
62 |
-
collisionColor =
|
63 |
jointNames = true,
|
64 |
onclick = defaultOnClick,
|
65 |
...restProps
|
@@ -68,10 +69,23 @@
|
|
68 |
if (isInteractive) {
|
69 |
interactivity();
|
70 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
71 |
</script>
|
72 |
|
73 |
{@html `<!-- Joint ${joint.name} (${joint.type}) -->`}
|
74 |
-
|
75 |
<!-- draw line from parent-frame to joint origin -->
|
76 |
{#if showLine}
|
77 |
<T.Line>
|
@@ -91,7 +105,7 @@
|
|
91 |
<UrdfLink
|
92 |
{robot}
|
93 |
link={joint.child}
|
94 |
-
textScale={0.
|
95 |
{showName}
|
96 |
{showVisual}
|
97 |
{showCollision}
|
@@ -141,7 +155,7 @@
|
|
141 |
<Text
|
142 |
scale={nameHeight}
|
143 |
color={selectedJoint == joint ? highlightColor : jointColor}
|
144 |
-
text={joint.name}
|
145 |
characters="ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
146 |
renderOrder={999}
|
147 |
></Text>
|
|
|
1 |
<script lang="ts">
|
2 |
+
import type IUrdfJoint from "../interfaces/IUrdfJoint";
|
3 |
+
import { T } from "@threlte/core";
|
4 |
+
import UrdfLink from "./UrdfLink.svelte";
|
5 |
+
import { Vector3 } from "three";
|
6 |
import {
|
7 |
Billboard,
|
8 |
interactivity,
|
9 |
MeshLineGeometry,
|
10 |
Text,
|
11 |
type InteractivityProps
|
12 |
+
} from "@threlte/extras";
|
13 |
|
14 |
+
import type IUrdfLink from "../interfaces/IUrdfLink";
|
15 |
+
import type IUrdfRobot from "../interfaces/IUrdfRobot";
|
16 |
+
import { radiansToDegrees } from "@/utils";
|
17 |
|
18 |
+
const defaultOnClick: InteractivityProps["onclick"] = (event) => {
|
19 |
event.stopPropagation();
|
20 |
selectedLink = undefined;
|
21 |
selectedJoint = joint;
|
|
|
50 |
selectedLink = $bindable(),
|
51 |
showLine = false,
|
52 |
nameHeight = 0.02,
|
53 |
+
highlightColor = "#ff0000",
|
54 |
+
jointColor = "#000000",
|
55 |
+
jointIndicatorColor = "#000000",
|
56 |
opacity = 0.7,
|
57 |
isInteractive = false,
|
58 |
showName = false,
|
|
|
60 |
showCollision = true,
|
61 |
visualOpacity = 1,
|
62 |
collisionOpacity = 1,
|
63 |
+
collisionColor = "#000000",
|
64 |
jointNames = true,
|
65 |
onclick = defaultOnClick,
|
66 |
...restProps
|
|
|
69 |
if (isInteractive) {
|
70 |
interactivity();
|
71 |
}
|
72 |
+
|
73 |
+
// Helper function to get current joint rotation value in degrees
|
74 |
+
function getJointRotationValue(joint: any): number {
|
75 |
+
const axis = joint.axis_xyz || [0, 0, 1];
|
76 |
+
const rotation = joint.rotation || [0, 0, 0];
|
77 |
+
|
78 |
+
// Find the primary axis and get the rotation value
|
79 |
+
for (let i = 0; i < 3; i++) {
|
80 |
+
if (Math.abs(axis[i]) > 0.001) {
|
81 |
+
return radiansToDegrees(rotation[i] / axis[i]);
|
82 |
+
}
|
83 |
+
}
|
84 |
+
return 0;
|
85 |
+
}
|
86 |
</script>
|
87 |
|
88 |
{@html `<!-- Joint ${joint.name} (${joint.type}) -->`}
|
|
|
89 |
<!-- draw line from parent-frame to joint origin -->
|
90 |
{#if showLine}
|
91 |
<T.Line>
|
|
|
105 |
<UrdfLink
|
106 |
{robot}
|
107 |
link={joint.child}
|
108 |
+
textScale={0.2}
|
109 |
{showName}
|
110 |
{showVisual}
|
111 |
{showCollision}
|
|
|
155 |
<Text
|
156 |
scale={nameHeight}
|
157 |
color={selectedJoint == joint ? highlightColor : jointColor}
|
158 |
+
text={joint.name + " " + getJointRotationValue(joint).toFixed(0) + "°"}
|
159 |
characters="ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
160 |
renderOrder={999}
|
161 |
></Text>
|
src/lib/components/3d/robot/URDF/primitives/UrdfLink.svelte
CHANGED
@@ -1,12 +1,11 @@
|
|
1 |
<script lang="ts">
|
2 |
// The link element describes a rigid body with an inertia, visual features, and collision properties.
|
3 |
-
import type IUrdfLink from
|
4 |
-
import UrdfVisual from
|
5 |
-
import { getChildJoints } from
|
6 |
-
import UrdfJoint from
|
7 |
-
import
|
8 |
-
import type
|
9 |
-
import type IUrdfJoint from '../interfaces/IUrdfJoint';
|
10 |
|
11 |
interface Props {
|
12 |
robot: IUrdfRobot;
|
@@ -39,18 +38,18 @@
|
|
39 |
showName = true,
|
40 |
showVisual = true,
|
41 |
showCollision = true,
|
42 |
-
visualColor =
|
43 |
visualOpacity = 1,
|
44 |
collisionOpacity = 1,
|
45 |
-
collisionColor =
|
46 |
jointNames = true,
|
47 |
joints = true,
|
48 |
-
jointColor =
|
49 |
-
jointIndicatorColor =
|
50 |
nameHeight = 0.1,
|
51 |
selectedLink = undefined,
|
52 |
selectedJoint = undefined,
|
53 |
-
highlightColor =
|
54 |
showLine = true,
|
55 |
opacity = 0.7,
|
56 |
isInteractive = false
|
@@ -58,30 +57,22 @@
|
|
58 |
</script>
|
59 |
|
60 |
{@html `<!-- Link ${link.name} -->`}
|
61 |
-
{#if showName}
|
62 |
<Billboard position.x={0} position.y={0} position.z={0}>
|
63 |
<Text anchorY={-0.2} scale={textScale} text={link.name} characters="ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
64 |
></Text>
|
65 |
</Billboard>
|
66 |
-
{/if}
|
67 |
|
68 |
{#if showVisual}
|
69 |
{#each link.visual as visual}
|
70 |
-
<UrdfVisual
|
71 |
-
opacity={visualOpacity}
|
72 |
-
{visual}
|
73 |
-
defaultColor={visualColor}
|
74 |
-
/>
|
75 |
{/each}
|
76 |
{/if}
|
77 |
|
78 |
{#if showCollision}
|
79 |
{#each link.collision as visual}
|
80 |
-
<UrdfVisual
|
81 |
-
opacity={collisionOpacity}
|
82 |
-
visual={visual}
|
83 |
-
defaultColor={collisionColor}
|
84 |
-
/>
|
85 |
{/each}
|
86 |
{/if}
|
87 |
|
|
|
1 |
<script lang="ts">
|
2 |
// The link element describes a rigid body with an inertia, visual features, and collision properties.
|
3 |
+
import type IUrdfLink from "../interfaces/IUrdfLink";
|
4 |
+
import UrdfVisual from "./UrdfVisual.svelte";
|
5 |
+
import { getChildJoints } from "../utils/UrdfParser";
|
6 |
+
import UrdfJoint from "./UrdfJoint.svelte";
|
7 |
+
import type IUrdfRobot from "../interfaces/IUrdfRobot";
|
8 |
+
import type IUrdfJoint from "../interfaces/IUrdfJoint";
|
|
|
9 |
|
10 |
interface Props {
|
11 |
robot: IUrdfRobot;
|
|
|
38 |
showName = true,
|
39 |
showVisual = true,
|
40 |
showCollision = true,
|
41 |
+
visualColor = "#000000",
|
42 |
visualOpacity = 1,
|
43 |
collisionOpacity = 1,
|
44 |
+
collisionColor = "#000000",
|
45 |
jointNames = true,
|
46 |
joints = true,
|
47 |
+
jointColor = "#000000",
|
48 |
+
jointIndicatorColor = "#000000",
|
49 |
nameHeight = 0.1,
|
50 |
selectedLink = undefined,
|
51 |
selectedJoint = undefined,
|
52 |
+
highlightColor = "#000000",
|
53 |
showLine = true,
|
54 |
opacity = 0.7,
|
55 |
isInteractive = false
|
|
|
57 |
</script>
|
58 |
|
59 |
{@html `<!-- Link ${link.name} -->`}
|
60 |
+
<!-- {#if showName}
|
61 |
<Billboard position.x={0} position.y={0} position.z={0}>
|
62 |
<Text anchorY={-0.2} scale={textScale} text={link.name} characters="ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
63 |
></Text>
|
64 |
</Billboard>
|
65 |
+
{/if} -->
|
66 |
|
67 |
{#if showVisual}
|
68 |
{#each link.visual as visual}
|
69 |
+
<UrdfVisual opacity={visualOpacity} {visual} defaultColor={visualColor} />
|
|
|
|
|
|
|
|
|
70 |
{/each}
|
71 |
{/if}
|
72 |
|
73 |
{#if showCollision}
|
74 |
{#each link.collision as visual}
|
75 |
+
<UrdfVisual opacity={collisionOpacity} {visual} defaultColor={collisionColor} />
|
|
|
|
|
|
|
|
|
76 |
{/each}
|
77 |
{/if}
|
78 |
|
src/lib/components/3d/robot/URDF/primitives/UrdfThree.svelte
CHANGED
@@ -1,12 +1,12 @@
|
|
1 |
<script lang="ts">
|
2 |
// Three.js visualization of an URDF.
|
3 |
-
import { T } from
|
4 |
-
import { getRootLinks } from
|
5 |
-
import UrdfLink from
|
6 |
-
import { scale } from
|
7 |
-
import type IUrdfLink from
|
8 |
-
import type IUrdfJoint from
|
9 |
-
import type IUrdfRobot from
|
10 |
|
11 |
interface Props {
|
12 |
robot: IUrdfRobot;
|
@@ -37,16 +37,16 @@
|
|
37 |
showCollision = true,
|
38 |
visualOpacity = 1,
|
39 |
collisionOpacity = 1,
|
40 |
-
collisionColor =
|
41 |
jointNames = true,
|
42 |
joints = true,
|
43 |
-
jointColor =
|
44 |
-
jointIndicatorColor =
|
45 |
nameHeight = 0.1,
|
46 |
selectedLink = undefined,
|
47 |
selectedJoint = undefined,
|
48 |
textScale = 1,
|
49 |
-
highlightColor =
|
50 |
}: Props = $props();
|
51 |
</script>
|
52 |
|
|
|
1 |
<script lang="ts">
|
2 |
// Three.js visualization of an URDF.
|
3 |
+
import { T } from "@threlte/core";
|
4 |
+
import { getRootLinks } from "../utils/UrdfParser";
|
5 |
+
import UrdfLink from "./UrdfLink.svelte";
|
6 |
+
import { scale } from "svelte/transition";
|
7 |
+
import type IUrdfLink from "../interfaces/IUrdfLink";
|
8 |
+
import type IUrdfJoint from "../interfaces/IUrdfJoint";
|
9 |
+
import type IUrdfRobot from "../interfaces/IUrdfRobot";
|
10 |
|
11 |
interface Props {
|
12 |
robot: IUrdfRobot;
|
|
|
37 |
showCollision = true,
|
38 |
visualOpacity = 1,
|
39 |
collisionOpacity = 1,
|
40 |
+
collisionColor = "#000000",
|
41 |
jointNames = true,
|
42 |
joints = true,
|
43 |
+
jointColor = "#000000",
|
44 |
+
jointIndicatorColor = "#000000",
|
45 |
nameHeight = 0.1,
|
46 |
selectedLink = undefined,
|
47 |
selectedJoint = undefined,
|
48 |
textScale = 1,
|
49 |
+
highlightColor = "#000000"
|
50 |
}: Props = $props();
|
51 |
</script>
|
52 |
|
src/lib/components/3d/robot/URDF/primitives/UrdfVisual.svelte
CHANGED
@@ -1,14 +1,12 @@
|
|
1 |
<script lang="ts">
|
2 |
-
import type { IUrdfVisual } from
|
3 |
-
import { numberArrayToColor } from
|
4 |
-
import DAE from
|
5 |
-
import OBJ from
|
6 |
-
import STL from
|
7 |
-
import { T } from
|
8 |
-
import {
|
9 |
-
import type
|
10 |
-
import { DoubleSide, type Side } from 'three';
|
11 |
-
import { getContext } from 'svelte';
|
12 |
|
13 |
type Props = InteractivityProps & {
|
14 |
visual: IUrdfVisual;
|
@@ -26,7 +24,7 @@
|
|
26 |
let {
|
27 |
visual,
|
28 |
opacity = 1.0,
|
29 |
-
defaultColor =
|
30 |
position = [0, 0, 0],
|
31 |
rotation = [0, 0, 0],
|
32 |
castShadow = true,
|
@@ -41,15 +39,13 @@
|
|
41 |
const baseColor = visual?.color_rgba
|
42 |
? numberArrayToColor(visual.color_rgba.slice(0, 3) as [number, number, number])
|
43 |
: defaultColor;
|
44 |
-
|
45 |
-
|
46 |
</script>
|
47 |
|
48 |
-
{#if visual.type ===
|
49 |
-
{#if visual.geometry.type ===
|
50 |
<STL
|
51 |
color={baseColor}
|
52 |
-
|
53 |
filename={visual.geometry.filename}
|
54 |
scale={visual.geometry.scale}
|
55 |
{position}
|
@@ -60,10 +56,10 @@
|
|
60 |
{side}
|
61 |
{...restProps}
|
62 |
/>
|
63 |
-
{:else if visual.geometry.type ===
|
64 |
<OBJ
|
65 |
color={baseColor}
|
66 |
-
|
67 |
scale={visual.geometry.scale}
|
68 |
filename={visual.geometry.filename}
|
69 |
{position}
|
@@ -74,11 +70,11 @@
|
|
74 |
{side}
|
75 |
{...restProps}
|
76 |
/>
|
77 |
-
{:else if visual.geometry.type ===
|
78 |
<DAE
|
79 |
filename={visual.geometry.filename}
|
80 |
color={baseColor}
|
81 |
-
|
82 |
scale={visual.geometry.scale}
|
83 |
{position}
|
84 |
{rotation}
|
@@ -89,24 +85,16 @@
|
|
89 |
{...restProps}
|
90 |
/>
|
91 |
{/if}
|
92 |
-
{:else if visual.type ===
|
93 |
<T.Mesh castShadow receiveShadow rotation={[Math.PI / 2, 0, 0]} {onclick}>
|
94 |
<T.CylinderGeometry
|
95 |
args={[visual.geometry.radius, visual.geometry.radius, visual.geometry.length]}
|
96 |
/>
|
97 |
-
<T.MeshBasicMaterial
|
98 |
-
color={baseColor}
|
99 |
-
opacity={opacity}
|
100 |
-
transparent={opacity < 1.0}
|
101 |
-
/>
|
102 |
</T.Mesh>
|
103 |
-
{:else if visual.type ===
|
104 |
<T.Mesh castShadow receiveShadow scale={visual.geometry.size} {onclick}>
|
105 |
<T.BoxGeometry />
|
106 |
-
<T.MeshBasicMaterial
|
107 |
-
color={baseColor}
|
108 |
-
opacity={opacity}
|
109 |
-
transparent={opacity < 1.0}
|
110 |
-
/>
|
111 |
</T.Mesh>
|
112 |
{/if}
|
|
|
1 |
<script lang="ts">
|
2 |
+
import type { IUrdfVisual } from "../interfaces/IUrdfVisual";
|
3 |
+
import { numberArrayToColor } from "../utils/helper";
|
4 |
+
import DAE from "../mesh/DAE.svelte";
|
5 |
+
import OBJ from "../mesh/OBJ.svelte";
|
6 |
+
import STL from "../mesh/STL.svelte";
|
7 |
+
import { T } from "@threlte/core";
|
8 |
+
import { type InteractivityProps } from "@threlte/extras";
|
9 |
+
import { DoubleSide, type Side } from "three";
|
|
|
|
|
10 |
|
11 |
type Props = InteractivityProps & {
|
12 |
visual: IUrdfVisual;
|
|
|
24 |
let {
|
25 |
visual,
|
26 |
opacity = 1.0,
|
27 |
+
defaultColor = "#000000",
|
28 |
position = [0, 0, 0],
|
29 |
rotation = [0, 0, 0],
|
30 |
castShadow = true,
|
|
|
39 |
const baseColor = visual?.color_rgba
|
40 |
? numberArrayToColor(visual.color_rgba.slice(0, 3) as [number, number, number])
|
41 |
: defaultColor;
|
|
|
|
|
42 |
</script>
|
43 |
|
44 |
+
{#if visual.type === "mesh"}
|
45 |
+
{#if visual.geometry.type === "stl"}
|
46 |
<STL
|
47 |
color={baseColor}
|
48 |
+
{opacity}
|
49 |
filename={visual.geometry.filename}
|
50 |
scale={visual.geometry.scale}
|
51 |
{position}
|
|
|
56 |
{side}
|
57 |
{...restProps}
|
58 |
/>
|
59 |
+
{:else if visual.geometry.type === "obj"}
|
60 |
<OBJ
|
61 |
color={baseColor}
|
62 |
+
{opacity}
|
63 |
scale={visual.geometry.scale}
|
64 |
filename={visual.geometry.filename}
|
65 |
{position}
|
|
|
70 |
{side}
|
71 |
{...restProps}
|
72 |
/>
|
73 |
+
{:else if visual.geometry.type === "dae"}
|
74 |
<DAE
|
75 |
filename={visual.geometry.filename}
|
76 |
color={baseColor}
|
77 |
+
{opacity}
|
78 |
scale={visual.geometry.scale}
|
79 |
{position}
|
80 |
{rotation}
|
|
|
85 |
{...restProps}
|
86 |
/>
|
87 |
{/if}
|
88 |
+
{:else if visual.type === "cylinder"}
|
89 |
<T.Mesh castShadow receiveShadow rotation={[Math.PI / 2, 0, 0]} {onclick}>
|
90 |
<T.CylinderGeometry
|
91 |
args={[visual.geometry.radius, visual.geometry.radius, visual.geometry.length]}
|
92 |
/>
|
93 |
+
<T.MeshBasicMaterial color={baseColor} {opacity} transparent={opacity < 1.0} />
|
|
|
|
|
|
|
|
|
94 |
</T.Mesh>
|
95 |
+
{:else if visual.type === "box"}
|
96 |
<T.Mesh castShadow receiveShadow scale={visual.geometry.size} {onclick}>
|
97 |
<T.BoxGeometry />
|
98 |
+
<T.MeshBasicMaterial color={baseColor} {opacity} transparent={opacity < 1.0} />
|
|
|
|
|
|
|
|
|
99 |
</T.Mesh>
|
100 |
{/if}
|
src/lib/components/3d/robot/URDF/runes/urdf_state.svelte.ts
CHANGED
@@ -1,15 +1,15 @@
|
|
1 |
-
import type IUrdfJoint from "../interfaces/IUrdfJoint"
|
2 |
-
import type IUrdfLink from "../interfaces/IUrdfLink"
|
3 |
-
import type IUrdfRobot from "../interfaces/IUrdfRobot"
|
4 |
|
5 |
// Color constants for better maintainability
|
6 |
export const URDF_COLORS = {
|
7 |
-
|
8 |
-
|
9 |
-
|
10 |
-
|
11 |
-
|
12 |
-
|
13 |
} as const;
|
14 |
|
15 |
// Transform tool types
|
@@ -17,128 +17,128 @@ export type TransformTool = "translate" | "rotate" | "scale";
|
|
17 |
|
18 |
// Joint state tracking
|
19 |
export interface JointStates {
|
20 |
-
|
21 |
-
|
22 |
}
|
23 |
|
24 |
// Selection state
|
25 |
export interface SelectionState {
|
26 |
-
|
27 |
-
|
28 |
}
|
29 |
|
30 |
// Visibility configuration
|
31 |
export interface VisibilityConfig {
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
}
|
38 |
|
39 |
// Appearance settings
|
40 |
export interface AppearanceConfig {
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
}
|
55 |
|
56 |
// Editor configuration
|
57 |
export interface EditorConfig {
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
}
|
66 |
|
67 |
// View configuration
|
68 |
export interface ViewConfig {
|
69 |
-
|
70 |
-
|
71 |
-
|
72 |
-
|
73 |
-
|
74 |
}
|
75 |
|
76 |
// Main URDF state interface
|
77 |
export interface UrdfState extends SelectionState {
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
-
|
82 |
-
|
83 |
-
|
84 |
}
|
85 |
|
86 |
// Create the reactive state
|
87 |
export const urdfState = $state<UrdfState>({
|
88 |
-
|
89 |
-
|
90 |
-
|
91 |
-
|
92 |
-
|
93 |
-
|
94 |
-
|
95 |
-
|
96 |
-
|
97 |
-
|
98 |
-
|
99 |
-
|
100 |
-
|
101 |
-
|
102 |
-
|
103 |
-
|
104 |
-
|
105 |
-
|
106 |
-
|
107 |
-
|
108 |
-
|
109 |
-
|
110 |
-
|
111 |
-
|
112 |
-
|
113 |
-
|
114 |
-
|
115 |
-
|
116 |
-
|
117 |
-
|
118 |
-
|
119 |
-
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
|
125 |
-
|
126 |
-
|
127 |
-
|
128 |
-
|
129 |
-
|
130 |
-
|
131 |
-
|
132 |
-
|
133 |
-
|
134 |
-
|
135 |
-
|
136 |
-
|
137 |
-
|
138 |
-
|
139 |
-
|
140 |
-
|
141 |
-
|
142 |
-
|
143 |
-
|
144 |
-
});
|
|
|
1 |
+
import type IUrdfJoint from "../interfaces/IUrdfJoint";
|
2 |
+
import type IUrdfLink from "../interfaces/IUrdfLink";
|
3 |
+
import type IUrdfRobot from "../interfaces/IUrdfRobot";
|
4 |
|
5 |
// Color constants for better maintainability
|
6 |
export const URDF_COLORS = {
|
7 |
+
COLLISION: "#813d9c", // purple
|
8 |
+
JOINT: "#62a0ea", // blue
|
9 |
+
LINK: "#57e389", // green
|
10 |
+
JOINT_INDICATOR: "#f66151", // red
|
11 |
+
HIGHLIGHT: "#ffa348", // orange
|
12 |
+
BACKGROUND: "#241f31" // dark purple
|
13 |
} as const;
|
14 |
|
15 |
// Transform tool types
|
|
|
17 |
|
18 |
// Joint state tracking
|
19 |
export interface JointStates {
|
20 |
+
continuous: Record<string, number>;
|
21 |
+
revolute: Record<string, number>;
|
22 |
}
|
23 |
|
24 |
// Selection state
|
25 |
export interface SelectionState {
|
26 |
+
selectedLink?: IUrdfLink;
|
27 |
+
selectedJoint?: IUrdfJoint;
|
28 |
}
|
29 |
|
30 |
// Visibility configuration
|
31 |
export interface VisibilityConfig {
|
32 |
+
visual: boolean;
|
33 |
+
collision: boolean;
|
34 |
+
joints: boolean;
|
35 |
+
jointNames: boolean;
|
36 |
+
linkNames: boolean;
|
37 |
}
|
38 |
|
39 |
// Appearance settings
|
40 |
export interface AppearanceConfig {
|
41 |
+
colors: {
|
42 |
+
collision: string;
|
43 |
+
joint: string;
|
44 |
+
link: string;
|
45 |
+
jointIndicator: string;
|
46 |
+
highlight: string;
|
47 |
+
background: string;
|
48 |
+
};
|
49 |
+
opacity: {
|
50 |
+
visual: number;
|
51 |
+
collision: number;
|
52 |
+
link: number;
|
53 |
+
};
|
54 |
}
|
55 |
|
56 |
// Editor configuration
|
57 |
export interface EditorConfig {
|
58 |
+
isEditMode: boolean;
|
59 |
+
currentTool: TransformTool;
|
60 |
+
snap: {
|
61 |
+
translation: number;
|
62 |
+
scale: number;
|
63 |
+
rotation: number;
|
64 |
+
};
|
65 |
}
|
66 |
|
67 |
// View configuration
|
68 |
export interface ViewConfig {
|
69 |
+
zoom: {
|
70 |
+
current: number;
|
71 |
+
initial: number;
|
72 |
+
};
|
73 |
+
nameHeight: number;
|
74 |
}
|
75 |
|
76 |
// Main URDF state interface
|
77 |
export interface UrdfState extends SelectionState {
|
78 |
+
robot?: IUrdfRobot;
|
79 |
+
jointStates: JointStates;
|
80 |
+
visibility: VisibilityConfig;
|
81 |
+
appearance: AppearanceConfig;
|
82 |
+
editor: EditorConfig;
|
83 |
+
view: ViewConfig;
|
84 |
}
|
85 |
|
86 |
// Create the reactive state
|
87 |
export const urdfState = $state<UrdfState>({
|
88 |
+
// Selection
|
89 |
+
selectedLink: undefined,
|
90 |
+
selectedJoint: undefined,
|
91 |
+
|
92 |
+
// Robot data
|
93 |
+
robot: undefined,
|
94 |
+
jointStates: {
|
95 |
+
continuous: {},
|
96 |
+
revolute: {}
|
97 |
+
},
|
98 |
+
|
99 |
+
// Visibility settings
|
100 |
+
visibility: {
|
101 |
+
visual: true,
|
102 |
+
collision: false,
|
103 |
+
joints: true,
|
104 |
+
jointNames: true,
|
105 |
+
linkNames: true
|
106 |
+
},
|
107 |
+
|
108 |
+
// Appearance settings
|
109 |
+
appearance: {
|
110 |
+
colors: {
|
111 |
+
collision: URDF_COLORS.COLLISION,
|
112 |
+
joint: URDF_COLORS.JOINT,
|
113 |
+
link: URDF_COLORS.LINK,
|
114 |
+
jointIndicator: URDF_COLORS.JOINT_INDICATOR,
|
115 |
+
highlight: URDF_COLORS.HIGHLIGHT,
|
116 |
+
background: URDF_COLORS.BACKGROUND
|
117 |
+
},
|
118 |
+
opacity: {
|
119 |
+
visual: 1.0,
|
120 |
+
collision: 0.7,
|
121 |
+
link: 1.0
|
122 |
+
}
|
123 |
+
},
|
124 |
+
|
125 |
+
// Editor configuration
|
126 |
+
editor: {
|
127 |
+
isEditMode: false,
|
128 |
+
currentTool: "translate",
|
129 |
+
snap: {
|
130 |
+
translation: 0.001,
|
131 |
+
scale: 0.001,
|
132 |
+
rotation: 1
|
133 |
+
}
|
134 |
+
},
|
135 |
+
|
136 |
+
// View configuration
|
137 |
+
view: {
|
138 |
+
zoom: {
|
139 |
+
current: 1.3,
|
140 |
+
initial: 1.3
|
141 |
+
},
|
142 |
+
nameHeight: 0.05
|
143 |
+
}
|
144 |
+
});
|
src/lib/components/3d/robot/URDF/utils/UrdfParser.ts
CHANGED
@@ -17,23 +17,23 @@ import type IUrdfRobot from "../interfaces/IUrdfRobot";
|
|
17 |
* @returns An array of IUrdfLink objects that have no parent joint (i.e. root links)
|
18 |
*/
|
19 |
export function getRootLinks(robot: IUrdfRobot): IUrdfLink[] {
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
}
|
38 |
|
39 |
/**
|
@@ -47,26 +47,26 @@ export function getRootLinks(robot: IUrdfRobot): IUrdfLink[] {
|
|
47 |
* @returns An array of IUrdfJoint objects with no parent joint (i.e. root joints)
|
48 |
*/
|
49 |
export function getRootJoints(robot: IUrdfRobot): IUrdfJoint[] {
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
|
67 |
-
|
68 |
-
|
69 |
-
|
70 |
}
|
71 |
|
72 |
/**
|
@@ -76,23 +76,20 @@ export function getRootJoints(robot: IUrdfRobot): IUrdfJoint[] {
|
|
76 |
* @param parent - An IUrdfLink object to use as the "parent" in comparison
|
77 |
* @returns A list of IUrdfJoint objects whose parent.name matches parent.name
|
78 |
*/
|
79 |
-
export function getChildJoints(
|
80 |
-
|
81 |
-
|
82 |
-
)
|
83 |
-
|
84 |
-
|
85 |
-
|
86 |
-
|
87 |
-
|
88 |
-
|
89 |
-
|
90 |
-
|
91 |
-
|
92 |
-
|
93 |
-
}
|
94 |
-
|
95 |
-
return childJoints;
|
96 |
}
|
97 |
|
98 |
/**
|
@@ -102,9 +99,9 @@ export function getChildJoints(
|
|
102 |
* @param posable - Either an IUrdfJoint or an IUrdfVisual whose `.elem` has an <origin> child
|
103 |
*/
|
104 |
export function updateOrigin(posable: IUrdfJoint | IUrdfVisual) {
|
105 |
-
|
106 |
-
|
107 |
-
|
108 |
}
|
109 |
|
110 |
/**
|
@@ -116,399 +113,400 @@ export function updateOrigin(posable: IUrdfJoint | IUrdfVisual) {
|
|
116 |
* 5) Build an IUrdfRobot data structure that is easier to traverse in JS/Three.js
|
117 |
*/
|
118 |
export class UrdfParser {
|
119 |
-
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
|
125 |
-
|
126 |
-
|
127 |
-
|
128 |
-
|
129 |
-
|
130 |
-
|
131 |
-
|
132 |
-
|
133 |
-
|
134 |
-
|
135 |
-
|
136 |
-
|
137 |
-
|
138 |
-
|
139 |
-
|
140 |
-
|
141 |
-
|
142 |
-
|
143 |
-
|
144 |
-
|
145 |
-
|
146 |
-
|
147 |
-
|
148 |
-
|
149 |
-
|
150 |
-
|
151 |
-
|
152 |
-
|
153 |
-
|
154 |
-
|
155 |
-
|
156 |
-
|
157 |
-
|
158 |
-
|
159 |
-
|
160 |
-
|
161 |
-
|
162 |
-
|
163 |
-
|
164 |
-
|
165 |
-
|
166 |
-
|
167 |
-
|
168 |
-
|
169 |
-
|
170 |
-
|
171 |
-
|
172 |
-
|
173 |
-
|
174 |
-
|
175 |
-
|
176 |
-
|
177 |
-
|
178 |
-
|
179 |
-
|
180 |
-
|
181 |
-
|
182 |
-
|
183 |
-
|
184 |
-
|
185 |
-
|
186 |
-
|
187 |
-
|
188 |
-
|
189 |
-
|
190 |
-
|
191 |
-
|
192 |
-
|
193 |
-
|
194 |
-
|
195 |
-
|
196 |
-
|
197 |
-
|
198 |
-
|
199 |
-
|
200 |
-
|
201 |
-
|
202 |
-
|
203 |
-
|
204 |
-
|
205 |
-
|
206 |
-
|
207 |
-
|
208 |
-
|
209 |
-
|
210 |
-
|
211 |
-
|
212 |
-
|
213 |
-
|
214 |
-
|
215 |
-
|
216 |
-
|
217 |
-
|
218 |
-
|
219 |
-
|
220 |
-
|
221 |
-
|
222 |
-
|
223 |
-
|
224 |
-
|
225 |
-
|
226 |
-
|
227 |
-
|
228 |
-
|
229 |
-
|
230 |
-
|
231 |
-
|
232 |
-
|
233 |
-
|
234 |
-
|
235 |
-
|
236 |
-
|
237 |
-
|
238 |
-
|
239 |
-
|
240 |
-
|
241 |
-
|
242 |
-
|
243 |
-
|
244 |
-
|
245 |
-
|
246 |
-
|
247 |
-
|
248 |
-
|
249 |
-
|
250 |
-
|
251 |
-
|
252 |
-
|
253 |
-
|
254 |
-
|
255 |
-
|
256 |
-
|
257 |
-
|
258 |
-
|
259 |
-
|
260 |
-
|
261 |
-
|
262 |
-
|
263 |
-
|
264 |
-
|
265 |
-
|
266 |
-
|
267 |
-
|
268 |
-
|
269 |
-
|
270 |
-
|
271 |
-
|
272 |
-
|
273 |
-
|
274 |
-
|
275 |
-
|
276 |
-
|
277 |
-
|
278 |
-
|
279 |
-
|
280 |
-
|
281 |
-
|
282 |
-
|
283 |
-
|
284 |
-
|
285 |
-
|
286 |
-
|
287 |
-
|
288 |
-
|
289 |
-
|
290 |
-
|
291 |
-
|
292 |
-
|
293 |
-
|
294 |
-
|
295 |
-
|
296 |
-
|
297 |
-
|
298 |
-
|
299 |
-
|
300 |
-
|
301 |
-
|
302 |
-
|
303 |
-
|
304 |
-
|
305 |
-
|
306 |
-
|
307 |
-
|
308 |
-
|
309 |
-
|
310 |
-
|
311 |
-
|
312 |
-
|
313 |
-
|
314 |
-
|
315 |
-
|
316 |
-
|
317 |
-
|
318 |
-
|
319 |
-
|
320 |
-
|
321 |
-
|
322 |
-
|
323 |
-
|
324 |
-
|
325 |
-
|
326 |
-
|
327 |
-
|
328 |
-
|
329 |
-
|
330 |
-
|
331 |
-
|
332 |
-
|
333 |
-
|
334 |
-
|
335 |
-
|
336 |
-
|
337 |
-
|
338 |
-
|
339 |
-
|
340 |
-
|
341 |
-
|
342 |
-
|
343 |
-
|
344 |
-
|
345 |
-
|
346 |
-
|
347 |
-
|
348 |
-
|
349 |
-
|
350 |
-
|
351 |
-
|
352 |
-
|
353 |
-
|
354 |
-
|
355 |
-
|
356 |
-
|
357 |
-
|
358 |
-
|
359 |
-
|
360 |
-
|
361 |
-
|
362 |
-
|
363 |
-
|
364 |
-
|
365 |
-
|
366 |
-
|
367 |
-
|
368 |
-
|
369 |
-
|
370 |
-
|
371 |
-
|
372 |
-
|
373 |
-
|
374 |
-
|
375 |
-
|
376 |
-
|
377 |
-
|
378 |
-
|
379 |
-
|
380 |
-
|
381 |
-
|
382 |
-
|
383 |
-
|
384 |
-
|
385 |
-
|
386 |
-
|
387 |
-
|
388 |
-
|
389 |
-
|
390 |
-
|
391 |
-
|
392 |
-
|
393 |
-
|
394 |
-
|
395 |
-
|
396 |
-
|
397 |
-
|
398 |
-
|
399 |
-
|
400 |
-
|
401 |
-
|
402 |
-
|
403 |
-
|
404 |
-
|
405 |
-
|
406 |
-
|
407 |
-
|
408 |
-
|
409 |
-
|
410 |
-
|
411 |
-
|
412 |
-
|
413 |
-
|
414 |
-
|
415 |
-
|
416 |
-
|
417 |
-
|
418 |
-
|
419 |
-
|
420 |
-
|
421 |
-
|
422 |
-
|
423 |
-
|
424 |
-
|
425 |
-
|
426 |
-
|
427 |
-
|
428 |
-
|
429 |
-
|
430 |
-
|
431 |
-
|
432 |
-
|
433 |
-
|
434 |
-
|
435 |
-
|
436 |
-
|
437 |
-
|
438 |
-
|
439 |
-
|
440 |
-
|
441 |
-
|
442 |
-
|
443 |
-
|
444 |
-
|
445 |
-
|
446 |
-
|
447 |
-
|
448 |
-
|
449 |
-
|
450 |
-
|
451 |
-
|
452 |
-
|
453 |
-
|
454 |
-
|
455 |
-
|
456 |
-
|
457 |
-
|
458 |
-
|
459 |
-
|
460 |
-
|
461 |
-
|
462 |
-
|
463 |
-
|
464 |
-
|
465 |
-
|
466 |
-
|
467 |
-
|
468 |
-
|
469 |
-
|
470 |
-
|
471 |
-
|
472 |
-
|
473 |
-
|
474 |
-
|
475 |
-
|
476 |
-
|
477 |
-
|
478 |
-
|
479 |
-
|
480 |
-
|
481 |
-
|
482 |
-
|
483 |
-
|
484 |
-
|
485 |
-
|
486 |
-
|
487 |
-
|
488 |
-
|
489 |
-
|
490 |
-
|
491 |
-
|
492 |
-
|
493 |
-
|
494 |
-
|
495 |
-
|
496 |
-
|
497 |
-
|
498 |
-
|
499 |
-
|
500 |
-
|
501 |
-
|
502 |
-
|
503 |
-
|
504 |
-
|
505 |
-
|
506 |
-
|
507 |
-
|
508 |
-
|
|
|
|
|
509 |
}
|
510 |
|
511 |
-
|
512 |
/**
|
513 |
* ==============================================================================
|
514 |
* Example of how the parsed data (IUrdfRobot) maps from the URDF XML ("so_arm100"):
|
|
|
17 |
* @returns An array of IUrdfLink objects that have no parent joint (i.e. root links)
|
18 |
*/
|
19 |
export function getRootLinks(robot: IUrdfRobot): IUrdfLink[] {
|
20 |
+
const links: IUrdfLink[] = [];
|
21 |
+
const joints = robot.joints;
|
22 |
+
|
23 |
+
for (const link of Object.values(robot.links)) {
|
24 |
+
let isRoot = true;
|
25 |
+
for (const joint of joints) {
|
26 |
+
if (joint.child.name === link.name) {
|
27 |
+
isRoot = false;
|
28 |
+
break;
|
29 |
+
}
|
30 |
+
}
|
31 |
+
if (isRoot) {
|
32 |
+
links.push(link);
|
33 |
+
}
|
34 |
+
}
|
35 |
+
|
36 |
+
return links;
|
37 |
}
|
38 |
|
39 |
/**
|
|
|
47 |
* @returns An array of IUrdfJoint objects with no parent joint (i.e. root joints)
|
48 |
*/
|
49 |
export function getRootJoints(robot: IUrdfRobot): IUrdfJoint[] {
|
50 |
+
const joints = robot.joints;
|
51 |
+
const rootJoints: IUrdfJoint[] = [];
|
52 |
+
|
53 |
+
for (const joint of joints) {
|
54 |
+
let isRoot = true;
|
55 |
+
|
56 |
+
// If any other joint's child matches this joint's parent, then this joint isn't root
|
57 |
+
for (const parentJoint of joints) {
|
58 |
+
if (joint.parent.name === parentJoint.child.name) {
|
59 |
+
isRoot = false;
|
60 |
+
break;
|
61 |
+
}
|
62 |
+
}
|
63 |
+
|
64 |
+
if (isRoot) {
|
65 |
+
rootJoints.push(joint);
|
66 |
+
}
|
67 |
+
}
|
68 |
+
|
69 |
+
return rootJoints;
|
70 |
}
|
71 |
|
72 |
/**
|
|
|
76 |
* @param parent - An IUrdfLink object to use as the "parent" in comparison
|
77 |
* @returns A list of IUrdfJoint objects whose parent.name matches parent.name
|
78 |
*/
|
79 |
+
export function getChildJoints(robot: IUrdfRobot, parent: IUrdfLink): IUrdfJoint[] {
|
80 |
+
const childJoints: IUrdfJoint[] = [];
|
81 |
+
const joints = robot.joints;
|
82 |
+
if (!joints) {
|
83 |
+
return [];
|
84 |
+
}
|
85 |
+
|
86 |
+
for (const joint of joints) {
|
87 |
+
if (joint.parent.name === parent.name) {
|
88 |
+
childJoints.push(joint);
|
89 |
+
}
|
90 |
+
}
|
91 |
+
|
92 |
+
return childJoints;
|
|
|
|
|
|
|
93 |
}
|
94 |
|
95 |
/**
|
|
|
99 |
* @param posable - Either an IUrdfJoint or an IUrdfVisual whose `.elem` has an <origin> child
|
100 |
*/
|
101 |
export function updateOrigin(posable: IUrdfJoint | IUrdfVisual) {
|
102 |
+
const origin = posable.elem.getElementsByTagName("origin")[0];
|
103 |
+
origin.setAttribute("xyz", posable.origin_xyz.join(" "));
|
104 |
+
origin.setAttribute("rpy", posable.origin_rpy.join(" "));
|
105 |
}
|
106 |
|
107 |
/**
|
|
|
113 |
* 5) Build an IUrdfRobot data structure that is easier to traverse in JS/Three.js
|
114 |
*/
|
115 |
export class UrdfParser {
|
116 |
+
filename: string;
|
117 |
+
prefix: string; // e.g. "robots/so_arm100/"
|
118 |
+
colors: { [name: string]: [number, number, number, number] } = {};
|
119 |
+
robot: IUrdfRobot = { name: "", links: {}, joints: [] };
|
120 |
+
|
121 |
+
/**
|
122 |
+
* @param filename - Path or URL to the URDF file (XML). May be relative.
|
123 |
+
* @param prefix - A folder prefix used when resolving "package://" or relative mesh paths.
|
124 |
+
*/
|
125 |
+
constructor(filename: string, prefix: string = "") {
|
126 |
+
this.filename = filename;
|
127 |
+
// Ensure prefix ends with exactly one slash
|
128 |
+
this.prefix = prefix.endsWith("/") ? prefix : prefix + "/";
|
129 |
+
}
|
130 |
+
|
131 |
+
/**
|
132 |
+
* Fetch the URDF file from `this.filename` and return its text.
|
133 |
+
* @returns A promise that resolves to the raw URDF XML string.
|
134 |
+
*/
|
135 |
+
async load(): Promise<string> {
|
136 |
+
return fetch(this.filename).then((res) => res.text());
|
137 |
+
}
|
138 |
+
|
139 |
+
/**
|
140 |
+
* Clear any previously parsed robot data, preparing for a fresh parse.
|
141 |
+
*/
|
142 |
+
reset() {
|
143 |
+
this.robot = { name: "", links: {}, joints: [] };
|
144 |
+
}
|
145 |
+
|
146 |
+
/**
|
147 |
+
* Parse a URDF XML string and produce an IUrdfRobot object.
|
148 |
+
*
|
149 |
+
* @param data - A string containing valid URDF XML.
|
150 |
+
* @returns The fully populated IUrdfRobot, including colors, links, and joints.
|
151 |
+
* @throws If the root element is not <robot>.
|
152 |
+
*/
|
153 |
+
fromString(data: string): IUrdfRobot {
|
154 |
+
this.reset();
|
155 |
+
const dom = new window.DOMParser().parseFromString(data, "text/xml");
|
156 |
+
this.robot.elem = dom.documentElement;
|
157 |
+
return this.parseRobotXMLNode(dom.documentElement);
|
158 |
+
}
|
159 |
+
|
160 |
+
/**
|
161 |
+
* Internal helper: ensure the root node is <robot>, then parse its children.
|
162 |
+
*
|
163 |
+
* @param robotNode - The <robot> Element from the DOMParser.
|
164 |
+
* @returns The populated IUrdfRobot data structure.
|
165 |
+
* @throws If robotNode.nodeName !== "robot"
|
166 |
+
*/
|
167 |
+
private parseRobotXMLNode(robotNode: Element): IUrdfRobot {
|
168 |
+
if (robotNode.nodeName !== "robot") {
|
169 |
+
throw new Error(`Invalid URDF: no <robot> (found <${robotNode.nodeName}>)`);
|
170 |
+
}
|
171 |
+
|
172 |
+
this.robot.name = robotNode.getAttribute("name") || "";
|
173 |
+
this.parseColorsFromRobot(robotNode);
|
174 |
+
this.parseLinks(robotNode);
|
175 |
+
this.parseJoints(robotNode);
|
176 |
+
return this.robot;
|
177 |
+
}
|
178 |
+
|
179 |
+
/**
|
180 |
+
* Look at all <material> tags under <robot> and store their names → RGBA values.
|
181 |
+
*
|
182 |
+
* @param robotNode - The <robot> Element.
|
183 |
+
*/
|
184 |
+
private parseColorsFromRobot(robotNode: Element) {
|
185 |
+
const xmlMaterials = robotNode.getElementsByTagName("material");
|
186 |
+
for (let i = 0; i < xmlMaterials.length; i++) {
|
187 |
+
const matNode = xmlMaterials[i];
|
188 |
+
if (!matNode.hasAttribute("name")) {
|
189 |
+
console.warn("Found <material> with no name attribute");
|
190 |
+
continue;
|
191 |
+
}
|
192 |
+
const name = matNode.getAttribute("name")!;
|
193 |
+
const colorTags = matNode.getElementsByTagName("color");
|
194 |
+
if (colorTags.length === 0) continue;
|
195 |
+
|
196 |
+
const colorElem = colorTags[0];
|
197 |
+
if (!colorElem.hasAttribute("rgba")) continue;
|
198 |
+
|
199 |
+
// e.g. "0.06 0.4 0.1 1.0"
|
200 |
+
const rgba = rgbaFromString(colorElem) || [0, 0, 0, 1];
|
201 |
+
this.colors[name] = rgba;
|
202 |
+
}
|
203 |
+
}
|
204 |
+
|
205 |
+
/**
|
206 |
+
* Parse every <link> under <robot> and build an IUrdfLink entry containing:
|
207 |
+
* - name
|
208 |
+
* - arrays of IUrdfVisual for <visual> tags
|
209 |
+
* - arrays of IUrdfVisual for <collision> tags
|
210 |
+
* - a pointer to its original XML Element (elem)
|
211 |
+
*
|
212 |
+
* @param robotNode - The <robot> Element.
|
213 |
+
*/
|
214 |
+
private parseLinks(robotNode: Element) {
|
215 |
+
const xmlLinks = robotNode.getElementsByTagName("link");
|
216 |
+
for (let i = 0; i < xmlLinks.length; i++) {
|
217 |
+
const linkXml = xmlLinks[i];
|
218 |
+
if (!linkXml.hasAttribute("name")) {
|
219 |
+
console.error("Link without a name:", linkXml);
|
220 |
+
continue;
|
221 |
+
}
|
222 |
+
const linkName = linkXml.getAttribute("name")!;
|
223 |
+
|
224 |
+
const linkObj: IUrdfLink = {
|
225 |
+
name: linkName,
|
226 |
+
visual: [],
|
227 |
+
collision: [],
|
228 |
+
elem: linkXml
|
229 |
+
};
|
230 |
+
this.robot.links[linkName] = linkObj;
|
231 |
+
|
232 |
+
// Parse all <visual> children
|
233 |
+
const visualXmls = linkXml.getElementsByTagName("visual");
|
234 |
+
for (let j = 0; j < visualXmls.length; j++) {
|
235 |
+
linkObj.visual.push(this.parseVisual(visualXmls[j]));
|
236 |
+
}
|
237 |
+
|
238 |
+
// Parse all <collision> children (reuse parseVisual; color is ignored later)
|
239 |
+
const collXmls = linkXml.getElementsByTagName("collision");
|
240 |
+
for (let j = 0; j < collXmls.length; j++) {
|
241 |
+
linkObj.collision.push(this.parseVisual(collXmls[j]));
|
242 |
+
}
|
243 |
+
}
|
244 |
+
}
|
245 |
+
|
246 |
+
/**
|
247 |
+
* Parse a <visual> or <collision> element into an IUrdfVisual. Reads:
|
248 |
+
* - <geometry> (calls parseGeometry to extract mesh, cylinder, box, etc.)
|
249 |
+
* - <origin> (xyz, rpy)
|
250 |
+
* - <material> (either embedded <color> or named reference)
|
251 |
+
*
|
252 |
+
* @param node - The <visual> or <collision> Element.
|
253 |
+
* @returns A fully populated IUrdfVisual object.
|
254 |
+
*/
|
255 |
+
private parseVisual(node: Element): IUrdfVisual {
|
256 |
+
const visual: Partial<IUrdfVisual> = { elem: node };
|
257 |
+
|
258 |
+
for (let i = 0; i < node.childNodes.length; i++) {
|
259 |
+
const child = node.childNodes[i];
|
260 |
+
|
261 |
+
// Skip non-element nodes (like text nodes containing whitespace)
|
262 |
+
if (child.nodeType !== Node.ELEMENT_NODE) {
|
263 |
+
continue;
|
264 |
+
}
|
265 |
+
|
266 |
+
const childElement = child as Element;
|
267 |
+
switch (childElement.nodeName) {
|
268 |
+
case "geometry": {
|
269 |
+
this.parseGeometry(childElement, visual);
|
270 |
+
break;
|
271 |
+
}
|
272 |
+
case "origin": {
|
273 |
+
const pos = xyzFromString(childElement);
|
274 |
+
const rpy = rpyFromString(childElement);
|
275 |
+
if (pos) visual.origin_xyz = pos;
|
276 |
+
if (rpy) visual.origin_rpy = rpy;
|
277 |
+
break;
|
278 |
+
}
|
279 |
+
case "material": {
|
280 |
+
const cols = childElement.getElementsByTagName("color");
|
281 |
+
if (cols.length > 0 && cols[0].hasAttribute("rgba")) {
|
282 |
+
// Inline color specification
|
283 |
+
visual.color_rgba = rgbaFromString(cols[0])!;
|
284 |
+
} else if (childElement.hasAttribute("name")) {
|
285 |
+
// Named material → look up previously parsed RGBA
|
286 |
+
const nm = childElement.getAttribute("name")!;
|
287 |
+
visual.color_rgba = this.colors[nm];
|
288 |
+
}
|
289 |
+
break;
|
290 |
+
}
|
291 |
+
default: {
|
292 |
+
console.warn("Unknown child node:", childElement.nodeName);
|
293 |
+
break;
|
294 |
+
}
|
295 |
+
}
|
296 |
+
}
|
297 |
+
|
298 |
+
return visual as IUrdfVisual;
|
299 |
+
}
|
300 |
+
|
301 |
+
/**
|
302 |
+
* Parse a <geometry> element inside <visual> or <collision>.
|
303 |
+
* Currently only supports <mesh>. If you need <cylinder> or <box>,
|
304 |
+
* you can extend this function similarly.
|
305 |
+
*
|
306 |
+
* @param node - The <geometry> Element.
|
307 |
+
* @param visual - A partial IUrdfVisual object to populate
|
308 |
+
*/
|
309 |
+
private parseGeometry(node: Element, visual: Partial<IUrdfVisual>) {
|
310 |
+
for (let i = 0; i < node.childNodes.length; i++) {
|
311 |
+
const child = node.childNodes[i];
|
312 |
+
|
313 |
+
// Skip non-element nodes (like text nodes containing whitespace)
|
314 |
+
if (child.nodeType !== Node.ELEMENT_NODE) {
|
315 |
+
continue;
|
316 |
+
}
|
317 |
+
|
318 |
+
const childElement = child as Element;
|
319 |
+
if (childElement.nodeName === "mesh") {
|
320 |
+
const rawFilename = childElement.getAttribute("filename");
|
321 |
+
if (!rawFilename) {
|
322 |
+
console.warn("<mesh> missing filename!");
|
323 |
+
return;
|
324 |
+
}
|
325 |
+
|
326 |
+
// 1) Resolve the URL (handles "package://" or relative paths)
|
327 |
+
const resolvedUrl = this.resolveFilename(rawFilename);
|
328 |
+
|
329 |
+
// 2) Parse optional scale (e.g. "1 1 1")
|
330 |
+
let scale: [number, number, number] = [1, 1, 1];
|
331 |
+
if (childElement.hasAttribute("scale")) {
|
332 |
+
const parts = childElement.getAttribute("scale")!.split(" ").map(parseFloat);
|
333 |
+
if (parts.length === 3) {
|
334 |
+
scale = [parts[0], parts[1], parts[2]];
|
335 |
+
}
|
336 |
+
}
|
337 |
+
|
338 |
+
// 3) Deduce mesh type from file extension
|
339 |
+
const ext = resolvedUrl.slice(resolvedUrl.lastIndexOf(".") + 1).toLowerCase();
|
340 |
+
let type: "stl" | "fbx" | "obj" | "dae";
|
341 |
+
switch (ext) {
|
342 |
+
case "stl":
|
343 |
+
type = "stl";
|
344 |
+
break;
|
345 |
+
case "fbx":
|
346 |
+
type = "fbx";
|
347 |
+
break;
|
348 |
+
case "obj":
|
349 |
+
type = "obj";
|
350 |
+
break;
|
351 |
+
case "dae":
|
352 |
+
type = "dae";
|
353 |
+
break;
|
354 |
+
default:
|
355 |
+
throw new Error("Unknown mesh extension: " + ext);
|
356 |
+
}
|
357 |
+
|
358 |
+
visual.geometry = { filename: resolvedUrl, type, scale } as IUrdfMesh;
|
359 |
+
visual.type = "mesh";
|
360 |
+
return;
|
361 |
+
}
|
362 |
+
|
363 |
+
// If you also want <cylinder> or <box>, copy your previous logic here:
|
364 |
+
// e.g. if (childElement.nodeName === "cylinder") { … }
|
365 |
+
}
|
366 |
+
}
|
367 |
+
|
368 |
+
/**
|
369 |
+
* Transform a URI‐like string into an actual URL. Handles:
|
370 |
+
* 1) http(s):// or data: → leave unchanged
|
371 |
+
* 2) package://some_package/... → replace with prefix + "some_package/...
|
372 |
+
* 3) package:/some_package/... → same as above
|
373 |
+
* 4) Anything else (e.g. "meshes/Foo.stl") is treated as relative.
|
374 |
+
*
|
375 |
+
* @param raw - The raw filename from URDF (e.g. "meshes/Base.stl" or "package://my_pkg/mesh.dae")
|
376 |
+
* @returns The fully resolved URL string
|
377 |
+
*/
|
378 |
+
private resolveFilename(raw: string): string {
|
379 |
+
// 1) absolute http(s) or data URIs
|
380 |
+
if (/^https?:\/\//.test(raw) || raw.startsWith("data:")) {
|
381 |
+
return raw;
|
382 |
+
}
|
383 |
+
|
384 |
+
// 2) package://some_package/…
|
385 |
+
if (raw.startsWith("package://")) {
|
386 |
+
const rel = raw.substring("package://".length);
|
387 |
+
return this.joinUrl(this.prefix, rel);
|
388 |
+
}
|
389 |
+
|
390 |
+
// 3) package:/some_package/…
|
391 |
+
if (raw.startsWith("package:/")) {
|
392 |
+
const rel = raw.substring("package:/".length);
|
393 |
+
return this.joinUrl(this.prefix, rel);
|
394 |
+
}
|
395 |
+
|
396 |
+
// 4) anything else (e.g. "meshes/Foo.stl") is treated as relative
|
397 |
+
return this.joinUrl(this.prefix, raw);
|
398 |
+
}
|
399 |
+
|
400 |
+
/**
|
401 |
+
* Helper to join a base URL with a relative path, ensuring exactly one '/' in between
|
402 |
+
*
|
403 |
+
* @param base - e.g. "/robots/so_arm100/"
|
404 |
+
* @param rel - e.g. "meshes/Base.stl" (with or without a leading slash)
|
405 |
+
* @returns A string like "/robots/so_arm100/meshes/Base.stl"
|
406 |
+
*/
|
407 |
+
private joinUrl(base: string, rel: string): string {
|
408 |
+
if (!base.startsWith("/")) base = "/" + base;
|
409 |
+
if (!base.endsWith("/")) base = base + "/";
|
410 |
+
if (rel.startsWith("/")) rel = rel.substring(1);
|
411 |
+
return base + rel;
|
412 |
+
}
|
413 |
+
|
414 |
+
/**
|
415 |
+
* Parse every <joint> under <robot> and build an IUrdfJoint entry. For each joint:
|
416 |
+
* 1) parent link (lookup in `this.robot.links[parentName]`)
|
417 |
+
* 2) child link (lookup in `this.robot.links[childName]`)
|
418 |
+
* 3) origin: xyz + rpy
|
419 |
+
* 4) axis (default [0,0,1] if absent)
|
420 |
+
* 5) limit (if present, lower/upper/effort/velocity)
|
421 |
+
*
|
422 |
+
* @param robotNode - The <robot> Element.
|
423 |
+
* @throws If a joint references a link name that doesn't exist.
|
424 |
+
*/
|
425 |
+
private parseJoints(robotNode: Element) {
|
426 |
+
const links = this.robot.links;
|
427 |
+
const joints: IUrdfJoint[] = [];
|
428 |
+
this.robot.joints = joints;
|
429 |
+
|
430 |
+
const xmlJoints = robotNode.getElementsByTagName("joint");
|
431 |
+
for (let i = 0; i < xmlJoints.length; i++) {
|
432 |
+
const jointXml = xmlJoints[i];
|
433 |
+
const parentElems = jointXml.getElementsByTagName("parent");
|
434 |
+
const childElems = jointXml.getElementsByTagName("child");
|
435 |
+
if (parentElems.length !== 1 || childElems.length !== 1) {
|
436 |
+
console.warn("Joint without exactly one <parent> or <child>:", jointXml);
|
437 |
+
continue;
|
438 |
+
}
|
439 |
+
|
440 |
+
const parentName = parentElems[0].getAttribute("link")!;
|
441 |
+
const childName = childElems[0].getAttribute("link")!;
|
442 |
+
|
443 |
+
const parentLink = links[parentName];
|
444 |
+
const childLink = links[childName];
|
445 |
+
if (!parentLink || !childLink) {
|
446 |
+
throw new Error(`Joint references missing link: ${parentName} or ${childName}`);
|
447 |
+
}
|
448 |
+
|
449 |
+
// Default origin and rpy
|
450 |
+
let xyz: [number, number, number] = [0, 0, 0];
|
451 |
+
let rpy: [number, number, number] = [0, 0, 0];
|
452 |
+
const originTags = jointXml.getElementsByTagName("origin");
|
453 |
+
if (originTags.length === 1) {
|
454 |
+
xyz = xyzFromString(originTags[0]) || xyz;
|
455 |
+
rpy = rpyFromString(originTags[0]) || rpy;
|
456 |
+
}
|
457 |
+
|
458 |
+
// Default axis
|
459 |
+
let axis: [number, number, number] = [0, 0, 1];
|
460 |
+
const axisTags = jointXml.getElementsByTagName("axis");
|
461 |
+
if (axisTags.length === 1) {
|
462 |
+
axis = xyzFromString(axisTags[0]) || axis;
|
463 |
+
}
|
464 |
+
|
465 |
+
// Optional limit
|
466 |
+
let limit;
|
467 |
+
const limitTags = jointXml.getElementsByTagName("limit");
|
468 |
+
if (limitTags.length === 1) {
|
469 |
+
const lim = limitTags[0];
|
470 |
+
limit = {
|
471 |
+
lower: parseFloat(lim.getAttribute("lower") || "0"),
|
472 |
+
upper: parseFloat(lim.getAttribute("upper") || "0"),
|
473 |
+
effort: parseFloat(lim.getAttribute("effort") || "0"),
|
474 |
+
velocity: parseFloat(lim.getAttribute("velocity") || "0")
|
475 |
+
};
|
476 |
+
}
|
477 |
+
|
478 |
+
joints.push({
|
479 |
+
name: jointXml.getAttribute("name") || undefined,
|
480 |
+
type: jointXml.getAttribute("type") as
|
481 |
+
| "revolute"
|
482 |
+
| "continuous"
|
483 |
+
| "prismatic"
|
484 |
+
| "fixed"
|
485 |
+
| "floating"
|
486 |
+
| "planar",
|
487 |
+
origin_xyz: xyz,
|
488 |
+
origin_rpy: rpy,
|
489 |
+
axis_xyz: axis,
|
490 |
+
rotation: [0, 0, 0],
|
491 |
+
parent: parentLink,
|
492 |
+
child: childLink,
|
493 |
+
limit: limit,
|
494 |
+
elem: jointXml
|
495 |
+
});
|
496 |
+
}
|
497 |
+
}
|
498 |
+
|
499 |
+
/**
|
500 |
+
* If you ever want to re‐serialize the robot back to URDF XML,
|
501 |
+
* this method returns the stringified root <robot> element.
|
502 |
+
*
|
503 |
+
* @returns A string beginning with '<?xml version="1.0" ?>' followed by the current XML.
|
504 |
+
*/
|
505 |
+
getURDFXML(): string {
|
506 |
+
return this.robot.elem ? '<?xml version="1.0" ?>\n' + this.robot.elem.outerHTML : "";
|
507 |
+
}
|
508 |
}
|
509 |
|
|
|
510 |
/**
|
511 |
* ==============================================================================
|
512 |
* Example of how the parsed data (IUrdfRobot) maps from the URDF XML ("so_arm100"):
|
src/lib/components/3d/robot/URDF/utils/helper.ts
CHANGED
@@ -1,50 +1,53 @@
|
|
1 |
export function xyzFromString(child: Element): [x: number, y: number, z: number] | undefined {
|
2 |
-
|
3 |
-
|
4 |
-
|
5 |
-
|
6 |
-
|
7 |
}
|
8 |
|
9 |
-
export function rpyFromString(
|
10 |
-
|
11 |
-
|
12 |
-
|
13 |
-
|
14 |
-
|
|
|
|
|
15 |
}
|
16 |
|
17 |
-
export function rgbaFromString(
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
|
|
|
|
23 |
}
|
24 |
|
25 |
-
export function numberStringToArray(
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
}
|
40 |
}
|
41 |
|
42 |
export function radToEuler(rad: number): number {
|
43 |
-
|
44 |
}
|
45 |
|
46 |
export function numberArrayToColor([r, g, b]: [number, number, number]): string {
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
}
|
|
|
1 |
export function xyzFromString(child: Element): [x: number, y: number, z: number] | undefined {
|
2 |
+
const arr = numberStringToArray(child, "xyz");
|
3 |
+
if (!arr || arr.length != 3) {
|
4 |
+
return;
|
5 |
+
}
|
6 |
+
return arr as [x: number, y: number, z: number];
|
7 |
}
|
8 |
|
9 |
+
export function rpyFromString(
|
10 |
+
child: Element
|
11 |
+
): [roll: number, pitch: number, yaw: number] | undefined {
|
12 |
+
const arr = numberStringToArray(child, "rpy");
|
13 |
+
if (!arr || arr.length != 3) {
|
14 |
+
return;
|
15 |
+
}
|
16 |
+
return arr as [roll: number, pitch: number, yaw: number];
|
17 |
}
|
18 |
|
19 |
+
export function rgbaFromString(
|
20 |
+
child: Element
|
21 |
+
): [r: number, g: number, b: number, a: number] | undefined {
|
22 |
+
const arr = numberStringToArray(child, "rgba");
|
23 |
+
if (!arr || arr.length != 4) {
|
24 |
+
return;
|
25 |
+
}
|
26 |
+
return arr as [r: number, g: number, b: number, a: number];
|
27 |
}
|
28 |
|
29 |
+
export function numberStringToArray(child: Element, name: string = "xyz"): number[] | undefined {
|
30 |
+
// parse a list of values from a string
|
31 |
+
// (like "1.0 2.2 3.0" into an array like [1, 2.2, 3])
|
32 |
+
// used in URDF for position, orientation an color values
|
33 |
+
if (child.hasAttribute(name)) {
|
34 |
+
const xyzStr = child.getAttribute(name)?.split(" ");
|
35 |
+
if (xyzStr) {
|
36 |
+
const arr = [];
|
37 |
+
for (const nr of xyzStr) {
|
38 |
+
arr.push(parseFloat(nr));
|
39 |
+
}
|
40 |
+
return arr;
|
41 |
+
}
|
42 |
+
}
|
|
|
43 |
}
|
44 |
|
45 |
export function radToEuler(rad: number): number {
|
46 |
+
return (rad * 180) / Math.PI;
|
47 |
}
|
48 |
|
49 |
export function numberArrayToColor([r, g, b]: [number, number, number]): string {
|
50 |
+
const toHex = (n: number) => Math.round(n).toString(16).padStart(2, "0");
|
51 |
+
// 0.06, 0.4, 0.1, 1
|
52 |
+
return `#${toHex(r * 255)}${toHex(g * 255)}${toHex(b * 255)}`;
|
53 |
}
|
src/lib/components/ButtonBar.svelte
DELETED
@@ -1,161 +0,0 @@
|
|
1 |
-
<script lang="ts">
|
2 |
-
import { Button } from '$lib/components/ui/button';
|
3 |
-
import { Badge } from '$lib/components/ui/badge';
|
4 |
-
import * as Popover from '$lib/components/ui/popover';
|
5 |
-
import { toast } from 'svelte-sonner';
|
6 |
-
import { cn } from '$lib/utils';
|
7 |
-
import { robotManager } from "$lib/robot/RobotManager.svelte";
|
8 |
-
import { robotUrdfConfigMap } from "$lib/configs/robotUrdfConfig";
|
9 |
-
|
10 |
-
interface Props {
|
11 |
-
controlsOpen?: boolean;
|
12 |
-
onToggleControls?: () => void;
|
13 |
-
}
|
14 |
-
|
15 |
-
let {
|
16 |
-
controlsOpen = $bindable(false),
|
17 |
-
onToggleControls
|
18 |
-
}: Props = $props();
|
19 |
-
|
20 |
-
let isCreating = $state(false);
|
21 |
-
let selectedRobotType = $state("so-arm100"); // Default to SO-100
|
22 |
-
let showRobotSelector = $state(false);
|
23 |
-
|
24 |
-
// Get available robot types
|
25 |
-
const robotTypes = Object.keys(robotUrdfConfigMap);
|
26 |
-
|
27 |
-
// Function to add robot using the proper robot creation logic
|
28 |
-
async function addRobot() {
|
29 |
-
if (isCreating) return;
|
30 |
-
|
31 |
-
console.log(`Creating ${selectedRobotType} robot...`);
|
32 |
-
isCreating = true;
|
33 |
-
|
34 |
-
try {
|
35 |
-
const urdfConfig = robotUrdfConfigMap[selectedRobotType];
|
36 |
-
|
37 |
-
if (!urdfConfig) {
|
38 |
-
throw new Error(`Unknown robot type: ${selectedRobotType}`);
|
39 |
-
}
|
40 |
-
|
41 |
-
const robotId = `robot-${Date.now()}`;
|
42 |
-
console.log('Creating robot with ID:', robotId, 'and config:', urdfConfig);
|
43 |
-
|
44 |
-
const robot = await robotManager.createRobot(robotId, urdfConfig);
|
45 |
-
console.log('Robot created successfully:', robot);
|
46 |
-
|
47 |
-
toast.success("Robot Added", {
|
48 |
-
description: `${selectedRobotType} robot ${robotId.slice(0, 12)}... created successfully.`
|
49 |
-
});
|
50 |
-
|
51 |
-
showRobotSelector = false; // Close selector on success
|
52 |
-
|
53 |
-
} catch (error) {
|
54 |
-
console.error('Robot creation failed:', error);
|
55 |
-
toast.error("Failed to Add Robot", {
|
56 |
-
description: `Could not create ${selectedRobotType} robot: ${error}`
|
57 |
-
});
|
58 |
-
} finally {
|
59 |
-
isCreating = false;
|
60 |
-
}
|
61 |
-
}
|
62 |
-
|
63 |
-
// Quick add SO-100 function
|
64 |
-
async function quickAddSO100() {
|
65 |
-
selectedRobotType = "so-arm100";
|
66 |
-
await addRobot();
|
67 |
-
}
|
68 |
-
</script>
|
69 |
-
|
70 |
-
<!-- Button Bar Container -->
|
71 |
-
<div class="fixed top-4 left-4 z-50 flex gap-2">
|
72 |
-
<!-- Add Robot Button with Dropdown -->
|
73 |
-
<div class="flex">
|
74 |
-
<!-- Main Add Button (SO-100) -->
|
75 |
-
<Button
|
76 |
-
variant="default"
|
77 |
-
size="sm"
|
78 |
-
onclick={quickAddSO100}
|
79 |
-
disabled={isCreating}
|
80 |
-
class="shadow-lg bg-green-600 hover:bg-green-500 disabled:bg-gray-600 text-white border-green-500 transition-all duration-200 group rounded-r-none border-r-0"
|
81 |
-
>
|
82 |
-
<span class={cn(
|
83 |
-
"size-4 mr-2 transition-transform duration-200",
|
84 |
-
isCreating ? "icon-[mdi--loading] animate-spin" : "icon-[mdi--plus] group-hover:rotate-90"
|
85 |
-
)}></span>
|
86 |
-
{isCreating ? 'Creating...' : 'Add SO-100'}
|
87 |
-
</Button>
|
88 |
-
|
89 |
-
<!-- Dropdown Button -->
|
90 |
-
<Popover.Root bind:open={showRobotSelector}>
|
91 |
-
<Popover.Trigger>
|
92 |
-
{#snippet child({ props })}
|
93 |
-
<Button
|
94 |
-
{...props}
|
95 |
-
variant="default"
|
96 |
-
size="sm"
|
97 |
-
disabled={isCreating}
|
98 |
-
class="shadow-lg bg-green-600 hover:bg-green-500 disabled:bg-gray-600 text-white border-green-500 transition-all duration-200 rounded-l-none border-l border-green-400 px-2"
|
99 |
-
>
|
100 |
-
<span class="icon-[mdi--chevron-down] size-4"></span>
|
101 |
-
</Button>
|
102 |
-
{/snippet}
|
103 |
-
</Popover.Trigger>
|
104 |
-
<Popover.Content class="w-64 p-3 bg-slate-800 border-slate-600">
|
105 |
-
<div class="space-y-3">
|
106 |
-
<h4 class="text-sm font-medium text-slate-100">Select Robot Type</h4>
|
107 |
-
<div class="space-y-2">
|
108 |
-
{#each robotTypes as robotType}
|
109 |
-
<Button
|
110 |
-
variant={selectedRobotType === robotType ? "default" : "ghost"}
|
111 |
-
size="sm"
|
112 |
-
onclick={async () => {
|
113 |
-
selectedRobotType = robotType;
|
114 |
-
await addRobot();
|
115 |
-
}}
|
116 |
-
disabled={isCreating}
|
117 |
-
class="w-full justify-start text-left"
|
118 |
-
>
|
119 |
-
<span class="icon-[mdi--robot] size-4 mr-2"></span>
|
120 |
-
{robotType}
|
121 |
-
{#if robotType === "so-arm100"}
|
122 |
-
<Badge variant="secondary" class="ml-auto text-xs">SO-100</Badge>
|
123 |
-
{/if}
|
124 |
-
</Button>
|
125 |
-
{/each}
|
126 |
-
</div>
|
127 |
-
</div>
|
128 |
-
</Popover.Content>
|
129 |
-
</Popover.Root>
|
130 |
-
</div>
|
131 |
-
|
132 |
-
<!-- Controls Button -->
|
133 |
-
<Button
|
134 |
-
variant="outline"
|
135 |
-
size="sm"
|
136 |
-
onclick={onToggleControls}
|
137 |
-
class={cn(
|
138 |
-
"shadow-lg transition-all duration-200 border-slate-600 backdrop-blur-sm group",
|
139 |
-
controlsOpen
|
140 |
-
? "bg-blue-600/90 text-white hover:bg-blue-500 border-blue-500"
|
141 |
-
: "bg-slate-800/90 text-slate-100 hover:bg-slate-700"
|
142 |
-
)}
|
143 |
-
>
|
144 |
-
<span class={cn(
|
145 |
-
"size-4 mr-2 transition-transform duration-200",
|
146 |
-
controlsOpen
|
147 |
-
? "icon-[mdi--close] group-hover:rotate-90"
|
148 |
-
: "icon-[mdi--gamepad-variant] group-hover:scale-110"
|
149 |
-
)}></span>
|
150 |
-
{controlsOpen ? 'Close' : 'Controls'}
|
151 |
-
|
152 |
-
{#if !controlsOpen}
|
153 |
-
<Badge
|
154 |
-
variant="secondary"
|
155 |
-
class="ml-2 bg-blue-400/20 text-blue-300 border-blue-400/30 text-xs"
|
156 |
-
>
|
157 |
-
Robot
|
158 |
-
</Badge>
|
159 |
-
{/if}
|
160 |
-
</Button>
|
161 |
-
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/lib/components/ControlsSheet.svelte
DELETED
@@ -1,76 +0,0 @@
|
|
1 |
-
<script lang="ts">
|
2 |
-
import { Button } from '$lib/components/ui/button';
|
3 |
-
import * as Sheet from '$lib/components/ui/sheet';
|
4 |
-
import { Badge } from '$lib/components/ui/badge';
|
5 |
-
import { Separator } from '$lib/components/ui/separator';
|
6 |
-
import ControlPanel from '$lib/components/panel/ControlPanel.svelte';
|
7 |
-
|
8 |
-
interface Props {
|
9 |
-
isOpen?: boolean;
|
10 |
-
}
|
11 |
-
|
12 |
-
let { isOpen = $bindable(false) }: Props = $props();
|
13 |
-
|
14 |
-
function toggleSheet() {
|
15 |
-
isOpen = !isOpen;
|
16 |
-
}
|
17 |
-
</script>
|
18 |
-
|
19 |
-
<!-- Controls Sheet -->
|
20 |
-
<Sheet.Root bind:open={isOpen}>
|
21 |
-
<Sheet.Content
|
22 |
-
side="right"
|
23 |
-
class="w-80 sm:w-96 p-0 bg-gradient-to-b from-slate-700 to-slate-800 text-white border-l border-slate-600"
|
24 |
-
>
|
25 |
-
<!-- Header -->
|
26 |
-
<Sheet.Header class="border-b border-slate-600 bg-slate-700/80 backdrop-blur-sm p-6">
|
27 |
-
<div class="flex items-center justify-between">
|
28 |
-
<div class="flex items-center gap-3">
|
29 |
-
<span class="icon-[mdi--gamepad-variant] size-6 text-blue-400"></span>
|
30 |
-
<div>
|
31 |
-
<Sheet.Title class="text-xl font-semibold text-slate-100">
|
32 |
-
Robot Controls
|
33 |
-
</Sheet.Title>
|
34 |
-
<p class="text-sm text-slate-400 mt-1">
|
35 |
-
Control your robot
|
36 |
-
</p>
|
37 |
-
</div>
|
38 |
-
</div>
|
39 |
-
<Badge variant="secondary" class="bg-blue-400 text-slate-900 font-medium px-3">
|
40 |
-
Active
|
41 |
-
</Badge>
|
42 |
-
</div>
|
43 |
-
</Sheet.Header>
|
44 |
-
|
45 |
-
<!-- Content -->
|
46 |
-
<div class="flex-1 overflow-y-auto scrollbar-thin scrollbar-track-slate-700 scrollbar-thumb-slate-500 px-4">
|
47 |
-
<div class="space-y-4">
|
48 |
-
<div class="flex items-center gap-2 text-sm text-slate-300">
|
49 |
-
<span class="icon-[mdi--information-outline] size-4"></span>
|
50 |
-
Control your robot's movements and actions
|
51 |
-
</div>
|
52 |
-
<Separator class="bg-slate-600" />
|
53 |
-
<ControlPanel />
|
54 |
-
</div>
|
55 |
-
</div>
|
56 |
-
|
57 |
-
<!-- Footer -->
|
58 |
-
<div class="border-t border-slate-600 bg-slate-800/50 p-4">
|
59 |
-
<div class="flex items-center justify-between">
|
60 |
-
<div class="flex items-center gap-2 text-xs text-slate-400">
|
61 |
-
<span class="icon-[mdi--gamepad-variant] size-4"></span>
|
62 |
-
<span>Robot Controls</span>
|
63 |
-
</div>
|
64 |
-
<Button
|
65 |
-
variant="ghost"
|
66 |
-
size="sm"
|
67 |
-
onclick={toggleSheet}
|
68 |
-
class="text-slate-400 hover:text-slate-100 text-xs px-2 py-1 h-auto"
|
69 |
-
>
|
70 |
-
<span class="icon-[mdi--close] size-3 mr-1"></span>
|
71 |
-
Close
|
72 |
-
</Button>
|
73 |
-
</div>
|
74 |
-
</div>
|
75 |
-
</Sheet.Content>
|
76 |
-
</Sheet.Root>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/lib/components/Overlay.svelte
DELETED
@@ -1,36 +0,0 @@
|
|
1 |
-
<script lang="ts">
|
2 |
-
import ButtonBar from '$lib/components/ButtonBar.svelte';
|
3 |
-
import ControlsSheet from '$lib/components/ControlsSheet.svelte';
|
4 |
-
import SettingsSheet from '$lib/components/SettingsSheet.svelte';
|
5 |
-
|
6 |
-
interface Props {
|
7 |
-
// Optional props for controlling the state externally if needed
|
8 |
-
controlsOpen?: boolean;
|
9 |
-
settingsOpen?: boolean;
|
10 |
-
}
|
11 |
-
|
12 |
-
let {
|
13 |
-
controlsOpen = $bindable(false),
|
14 |
-
settingsOpen = $bindable(false)
|
15 |
-
}: Props = $props();
|
16 |
-
|
17 |
-
function toggleControls() {
|
18 |
-
controlsOpen = !controlsOpen;
|
19 |
-
}
|
20 |
-
|
21 |
-
function toggleSettings() {
|
22 |
-
settingsOpen = !settingsOpen;
|
23 |
-
}
|
24 |
-
</script>
|
25 |
-
|
26 |
-
<!-- Button Bar with Add Robot and Controls (Top Left) -->
|
27 |
-
<ButtonBar
|
28 |
-
bind:controlsOpen
|
29 |
-
onToggleControls={toggleControls}
|
30 |
-
/>
|
31 |
-
|
32 |
-
<!-- Controls Sheet (Right Side) -->
|
33 |
-
<ControlsSheet bind:isOpen={controlsOpen} />
|
34 |
-
|
35 |
-
<!-- Settings Button and Sheet (Top Right, Left Side) -->
|
36 |
-
<SettingsSheet bind:isOpen={settingsOpen} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/lib/components/PanelWrapper.svelte
DELETED
@@ -1,32 +0,0 @@
|
|
1 |
-
<script lang="ts">
|
2 |
-
import * as Card from '$lib/components/ui/card';
|
3 |
-
import { Separator } from '$lib/components/ui/separator';
|
4 |
-
|
5 |
-
interface Props {
|
6 |
-
title?: string;
|
7 |
-
subtitle?: string;
|
8 |
-
children: any;
|
9 |
-
}
|
10 |
-
|
11 |
-
let { title, subtitle, children }: Props = $props();
|
12 |
-
</script>
|
13 |
-
|
14 |
-
<Card.Root class="bg-slate-700/50 border-slate-600">
|
15 |
-
{#if title}
|
16 |
-
<Card.Header class="pb-3">
|
17 |
-
<Card.Title class="text-slate-100 flex items-center gap-2">
|
18 |
-
{title}
|
19 |
-
</Card.Title>
|
20 |
-
{#if subtitle}
|
21 |
-
<Card.Description class="text-slate-400 text-sm">
|
22 |
-
{subtitle}
|
23 |
-
</Card.Description>
|
24 |
-
{/if}
|
25 |
-
</Card.Header>
|
26 |
-
<Separator class="bg-slate-600" />
|
27 |
-
{/if}
|
28 |
-
|
29 |
-
<Card.Content class="p-4 space-y-4">
|
30 |
-
{@render children()}
|
31 |
-
</Card.Content>
|
32 |
-
</Card.Root>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/lib/components/RobotStatusDemo.svelte
DELETED
@@ -1,65 +0,0 @@
|
|
1 |
-
<script lang="ts">
|
2 |
-
import StatusCard from '$lib/components/StatusCard.svelte';
|
3 |
-
import { Button } from '$lib/components/ui/button';
|
4 |
-
import { Badge } from '$lib/components/ui/badge';
|
5 |
-
import { environment } from '$lib/runes/env.svelte';
|
6 |
-
|
7 |
-
// Sample usage of StatusCard component
|
8 |
-
const robotStats = $derived(() => {
|
9 |
-
const robotCount = Object.keys(environment.robots).length;
|
10 |
-
return {
|
11 |
-
robotCount,
|
12 |
-
status: (robotCount > 0 ? 'active' : 'inactive') as 'active' | 'inactive',
|
13 |
-
lastUpdate: new Date().toLocaleTimeString()
|
14 |
-
};
|
15 |
-
});
|
16 |
-
</script>
|
17 |
-
|
18 |
-
<div class="space-y-4">
|
19 |
-
<h3 class="text-lg font-semibold text-slate-100 mb-4">Robot Status Overview</h3>
|
20 |
-
|
21 |
-
<div class="grid grid-cols-1 gap-3">
|
22 |
-
<StatusCard
|
23 |
-
title="Connected Robots"
|
24 |
-
status={robotStats().status}
|
25 |
-
value={robotStats().robotCount}
|
26 |
-
description="Active robot instances"
|
27 |
-
icon="icon-[mdi--robot]"
|
28 |
-
>
|
29 |
-
{#if robotStats().robotCount > 0}
|
30 |
-
<div class="flex flex-wrap gap-1">
|
31 |
-
{#each Object.entries(environment.robots) as [id, robot]}
|
32 |
-
<Badge variant="outline" class="text-xs text-slate-300 border-slate-500">
|
33 |
-
{id.slice(0, 8)}...
|
34 |
-
</Badge>
|
35 |
-
{/each}
|
36 |
-
</div>
|
37 |
-
{:else}
|
38 |
-
<p class="text-xs text-slate-500">No robots currently active</p>
|
39 |
-
{/if}
|
40 |
-
</StatusCard>
|
41 |
-
|
42 |
-
<StatusCard
|
43 |
-
title="System Status"
|
44 |
-
status="active"
|
45 |
-
description="All systems operational"
|
46 |
-
icon="icon-[mdi--check-circle]"
|
47 |
-
>
|
48 |
-
<div class="text-xs text-slate-400">
|
49 |
-
Last updated: {robotStats().lastUpdate}
|
50 |
-
</div>
|
51 |
-
</StatusCard>
|
52 |
-
|
53 |
-
<StatusCard
|
54 |
-
title="Control Mode"
|
55 |
-
status="active"
|
56 |
-
value="Manual"
|
57 |
-
description="Direct joint control enabled"
|
58 |
-
icon="icon-[mdi--gamepad-variant]"
|
59 |
-
>
|
60 |
-
<Button size="sm" variant="outline" class="w-full text-xs">
|
61 |
-
Switch to Autonomous
|
62 |
-
</Button>
|
63 |
-
</StatusCard>
|
64 |
-
</div>
|
65 |
-
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/lib/components/SettingsSheet.svelte
DELETED
@@ -1,109 +0,0 @@
|
|
1 |
-
<script lang="ts">
|
2 |
-
import { Button } from '$lib/components/ui/button';
|
3 |
-
import * as Sheet from '$lib/components/ui/sheet';
|
4 |
-
import { Badge } from '$lib/components/ui/badge';
|
5 |
-
import { Separator } from '$lib/components/ui/separator';
|
6 |
-
import SettingsPanel from '$lib/components/panel/SettingsPanel.svelte';
|
7 |
-
import { cn } from '$lib/utils';
|
8 |
-
|
9 |
-
interface Props {
|
10 |
-
isOpen?: boolean;
|
11 |
-
}
|
12 |
-
|
13 |
-
let { isOpen = $bindable(false) }: Props = $props();
|
14 |
-
|
15 |
-
function toggleSheet() {
|
16 |
-
isOpen = !isOpen;
|
17 |
-
}
|
18 |
-
</script>
|
19 |
-
|
20 |
-
<!-- Settings Trigger Button -->
|
21 |
-
<div class="fixed top-4 right-4 z-50">
|
22 |
-
<Button
|
23 |
-
variant="outline"
|
24 |
-
size="sm"
|
25 |
-
onclick={toggleSheet}
|
26 |
-
class={cn(
|
27 |
-
"shadow-lg transition-all duration-200 border-slate-600 backdrop-blur-sm group",
|
28 |
-
isOpen
|
29 |
-
? "bg-orange-600/90 text-white hover:bg-orange-500 border-orange-500"
|
30 |
-
: "bg-slate-800/90 text-slate-100 hover:bg-slate-700"
|
31 |
-
)}
|
32 |
-
>
|
33 |
-
<span class={cn(
|
34 |
-
"size-4 mr-2 transition-transform duration-200",
|
35 |
-
isOpen
|
36 |
-
? "icon-[mdi--close] group-hover:rotate-90"
|
37 |
-
: "icon-[mdi--cog] group-hover:rotate-45"
|
38 |
-
)}></span>
|
39 |
-
{isOpen ? 'Close' : 'Settings'}
|
40 |
-
|
41 |
-
{#if !isOpen}
|
42 |
-
<Badge
|
43 |
-
variant="secondary"
|
44 |
-
class="ml-2 bg-orange-400/20 text-orange-300 border-orange-400/30 text-xs"
|
45 |
-
>
|
46 |
-
Joints
|
47 |
-
</Badge>
|
48 |
-
{/if}
|
49 |
-
</Button>
|
50 |
-
</div>
|
51 |
-
|
52 |
-
<!-- Settings Sheet -->
|
53 |
-
<Sheet.Root bind:open={isOpen}>
|
54 |
-
<Sheet.Content
|
55 |
-
side="left"
|
56 |
-
class="w-80 sm:w-96 p-0 bg-gradient-to-b from-slate-700 to-slate-800 text-white border-r border-slate-600"
|
57 |
-
>
|
58 |
-
<!-- Header -->
|
59 |
-
<Sheet.Header class="border-b border-slate-600 bg-slate-700/80 backdrop-blur-sm p-6">
|
60 |
-
<div class="flex items-center justify-between">
|
61 |
-
<div class="flex items-center gap-3">
|
62 |
-
<span class="icon-[mdi--cog] size-6 text-orange-400"></span>
|
63 |
-
<div>
|
64 |
-
<Sheet.Title class="text-xl font-semibold text-slate-100">
|
65 |
-
Robot Settings
|
66 |
-
</Sheet.Title>
|
67 |
-
<p class="text-sm text-slate-400 mt-1">
|
68 |
-
Configure joints and robot parameters
|
69 |
-
</p>
|
70 |
-
</div>
|
71 |
-
</div>
|
72 |
-
<Badge variant="secondary" class="bg-orange-400 text-slate-900 font-medium px-3">
|
73 |
-
Config
|
74 |
-
</Badge>
|
75 |
-
</div>
|
76 |
-
</Sheet.Header>
|
77 |
-
|
78 |
-
<!-- Content -->
|
79 |
-
<div class="flex-1 overflow-y-auto scrollbar-thin scrollbar-track-slate-700 scrollbar-thumb-slate-500 p-4">
|
80 |
-
<div class="space-y-4">
|
81 |
-
<div class="flex items-center gap-2 text-sm text-slate-300">
|
82 |
-
<span class="icon-[mdi--wrench] size-4"></span>
|
83 |
-
Configure joints and robot parameters
|
84 |
-
</div>
|
85 |
-
<Separator class="bg-slate-600" />
|
86 |
-
<SettingsPanel />
|
87 |
-
</div>
|
88 |
-
</div>
|
89 |
-
|
90 |
-
<!-- Footer -->
|
91 |
-
<div class="border-t border-slate-600 bg-slate-800/50 p-4">
|
92 |
-
<div class="flex items-center justify-between">
|
93 |
-
<div class="flex items-center gap-2 text-xs text-slate-400">
|
94 |
-
<span class="icon-[mdi--cog] size-4"></span>
|
95 |
-
<span>Robot Settings</span>
|
96 |
-
</div>
|
97 |
-
<Button
|
98 |
-
variant="ghost"
|
99 |
-
size="sm"
|
100 |
-
onclick={toggleSheet}
|
101 |
-
class="text-slate-400 hover:text-slate-100 text-xs px-2 py-1 h-auto"
|
102 |
-
>
|
103 |
-
<span class="icon-[mdi--close] size-3 mr-1"></span>
|
104 |
-
Close
|
105 |
-
</Button>
|
106 |
-
</div>
|
107 |
-
</div>
|
108 |
-
</Sheet.Content>
|
109 |
-
</Sheet.Root>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|