blanchon commited on
Commit
02eac4b
·
0 Parent(s):

Initial commit

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +52 -0
  2. .github/workflows/docker.yml +120 -0
  3. Dockerfile +65 -0
  4. README.md +353 -0
  5. client/js/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc +98 -0
  6. client/js/.gitignore +34 -0
  7. client/js/README.md +396 -0
  8. client/js/bun.lock +409 -0
  9. client/js/examples/basic-consumer.js +173 -0
  10. client/js/examples/basic-producer.js +144 -0
  11. client/js/package.json +77 -0
  12. client/js/src/index.ts +12 -0
  13. client/js/src/robotics/consumer.ts +108 -0
  14. client/js/src/robotics/core.ts +295 -0
  15. client/js/src/robotics/factory.ts +66 -0
  16. client/js/src/robotics/index.ts +16 -0
  17. client/js/src/robotics/producer.ts +114 -0
  18. client/js/src/robotics/types.ts +180 -0
  19. client/js/src/video/consumer.ts +430 -0
  20. client/js/src/video/core.ts +533 -0
  21. client/js/src/video/factory.ts +66 -0
  22. client/js/src/video/index.ts +16 -0
  23. client/js/src/video/producer.ts +439 -0
  24. client/js/src/video/types.ts +352 -0
  25. client/js/tsconfig.json +32 -0
  26. client/js/vite.config.ts +38 -0
  27. client/python/.DS_Store +0 -0
  28. client/python/README.md +242 -0
  29. client/python/__pycache__/test_ai_camera.cpython-312-pytest-8.4.0.pyc +0 -0
  30. client/python/examples/README.md +189 -0
  31. client/python/examples/__pycache__/test_ai_server_consumer.cpython-312-pytest-8.4.0.pyc +0 -0
  32. client/python/examples/__pycache__/test_consumer_fix.cpython-312-pytest-8.4.0.pyc +0 -0
  33. client/python/examples/basic_consumer.py +92 -0
  34. client/python/examples/basic_producer.py +77 -0
  35. client/python/examples/consumer_first_recorder.py +319 -0
  36. client/python/examples/context_manager_example.py +177 -0
  37. client/python/examples/producer_consumer_demo.py +220 -0
  38. client/python/examples/room_management.py +93 -0
  39. client/python/examples/test_consumer_fix.py +214 -0
  40. client/python/examples/video_consumer_example.py +198 -0
  41. client/python/examples/video_producer_example.py +239 -0
  42. client/python/pyproject.toml +25 -0
  43. client/python/src/__pycache__/__init__.cpython-312.pyc +0 -0
  44. client/python/src/lerobot_arena_client/__init__.py +22 -0
  45. client/python/src/lerobot_arena_client/__pycache__/__init__.cpython-312.pyc +0 -0
  46. client/python/src/lerobot_arena_client/__pycache__/client.cpython-312.pyc +0 -0
  47. client/python/src/lerobot_arena_client/__pycache__/client.cpython-313.pyc +0 -0
  48. client/python/src/lerobot_arena_client/client.py +513 -0
  49. client/python/src/lerobot_arena_client/video/__init__.py +160 -0
  50. client/python/src/lerobot_arena_client/video/__pycache__/__init__.cpython-312.pyc +0 -0
.dockerignore ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Git
2
+ .git
3
+ .gitignore
4
+
5
+ # Node modules and build artifacts
6
+ node_modules
7
+ .svelte-kit
8
+ demo/build
9
+ demo/node_modules
10
+ client/js/node_modules
11
+ client/js/dist
12
+
13
+ # Python cache and virtual environments
14
+ __pycache__
15
+ *.pyc
16
+ *.pyo
17
+ *.pyd
18
+ .Python
19
+ .venv
20
+ .pytest_cache
21
+ *.egg-info
22
+
23
+ # Development files
24
+ *.log
25
+ logs/
26
+ .DS_Store
27
+ Thumbs.db
28
+
29
+ # IDE files
30
+ .vscode
31
+ .idea
32
+ *.swp
33
+ *.swo
34
+
35
+ # Temporary files
36
+ *.tmp
37
+ *.temp
38
+
39
+ # Documentation
40
+ *.md
41
+ !README.md
42
+
43
+ # Test files
44
+ tests/
45
+ test_*
46
+
47
+ # Development configs
48
+ .eslintrc*
49
+ .prettierrc*
50
+ *.config.js
51
+ !svelte.config.js
52
+ !vite.config.ts
.github/workflows/docker.yml ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Docker Build and Test
2
+
3
+ on:
4
+ push:
5
+ branches: [ main, develop ]
6
+ paths:
7
+ - 'services/transport-server/**'
8
+ pull_request:
9
+ branches: [ main ]
10
+ paths:
11
+ - 'services/transport-server/**'
12
+
13
+ env:
14
+ REGISTRY: ghcr.io
15
+ IMAGE_NAME: lerobot-arena/transport-server
16
+
17
+ jobs:
18
+ build-and-test:
19
+ runs-on: ubuntu-latest
20
+ permissions:
21
+ contents: read
22
+ packages: write
23
+
24
+ steps:
25
+ - name: Checkout repository
26
+ uses: actions/checkout@v4
27
+
28
+ - name: Set up Docker Buildx
29
+ uses: docker/setup-buildx-action@v3
30
+
31
+ - name: Log in to Container Registry
32
+ if: github.event_name != 'pull_request'
33
+ uses: docker/login-action@v3
34
+ with:
35
+ registry: ${{ env.REGISTRY }}
36
+ username: ${{ github.actor }}
37
+ password: ${{ secrets.GITHUB_TOKEN }}
38
+
39
+ - name: Extract metadata
40
+ id: meta
41
+ uses: docker/metadata-action@v5
42
+ with:
43
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
44
+ tags: |
45
+ type=ref,event=branch
46
+ type=ref,event=pr
47
+ type=sha,prefix={{branch}}-
48
+ type=raw,value=latest,enable={{is_default_branch}}
49
+
50
+ - name: Build Docker image
51
+ uses: docker/build-push-action@v5
52
+ with:
53
+ context: ./services/transport-server
54
+ file: ./services/transport-server/Dockerfile
55
+ push: false
56
+ tags: ${{ steps.meta.outputs.tags }}
57
+ labels: ${{ steps.meta.outputs.labels }}
58
+ cache-from: type=gha
59
+ cache-to: type=gha,mode=max
60
+ load: true
61
+
62
+ - name: Test Docker image
63
+ run: |
64
+ # Start the container in background
65
+ docker run -d --name test-container -p 7860:7860 -e SERVE_FRONTEND=true ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
66
+
67
+ # Wait for container to start
68
+ sleep 30
69
+
70
+ # Test health endpoint
71
+ curl -f http://localhost:7860/health || exit 1
72
+
73
+ # Test API health endpoint
74
+ curl -f http://localhost:7860/api/health || exit 1
75
+
76
+ # Test frontend is served
77
+ curl -f http://localhost:7860/ | grep -q "LeRobot Arena" || exit 1
78
+
79
+ # Check logs for errors
80
+ docker logs test-container
81
+
82
+ # Stop container
83
+ docker stop test-container
84
+ docker rm test-container
85
+
86
+ - name: Push Docker image
87
+ if: github.event_name != 'pull_request'
88
+ uses: docker/build-push-action@v5
89
+ with:
90
+ context: ./services/transport-server
91
+ file: ./services/transport-server/Dockerfile
92
+ push: true
93
+ tags: ${{ steps.meta.outputs.tags }}
94
+ labels: ${{ steps.meta.outputs.labels }}
95
+ cache-from: type=gha
96
+ cache-to: type=gha,mode=max
97
+
98
+ security-scan:
99
+ runs-on: ubuntu-latest
100
+ needs: build-and-test
101
+ permissions:
102
+ contents: read
103
+ security-events: write
104
+
105
+ steps:
106
+ - name: Checkout repository
107
+ uses: actions/checkout@v4
108
+
109
+ - name: Run Trivy vulnerability scanner
110
+ uses: aquasecurity/trivy-action@master
111
+ with:
112
+ image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
113
+ format: 'sarif'
114
+ output: 'trivy-results.sarif'
115
+
116
+ - name: Upload Trivy scan results to GitHub Security tab
117
+ uses: github/codeql-action/upload-sarif@v2
118
+ if: always()
119
+ with:
120
+ sarif_file: 'trivy-results.sarif'
Dockerfile ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Multi-stage Dockerfile for LeRobot Arena Transport Server
2
+ # Stage 1: Build frontend with Bun (client library + demo)
3
+ FROM oven/bun:1-alpine AS frontend-builder
4
+
5
+ WORKDIR /app
6
+
7
+ # Install git for dependencies that might need it
8
+ RUN apk add --no-cache git
9
+
10
+ # Copy all JavaScript/TypeScript files
11
+ COPY client/js/ ./client/js/
12
+ COPY demo/ ./demo/
13
+
14
+ # Build and link client library
15
+ WORKDIR /app/client/js
16
+ RUN bun install
17
+ RUN bun run build
18
+ RUN bun link
19
+
20
+ # Build demo with linked client library
21
+ WORKDIR /app/demo
22
+ RUN bun link lerobot-arena-client
23
+ RUN bun install
24
+ RUN bun run build
25
+
26
+ # Stage 2: Python backend with uv
27
+ FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
28
+
29
+ # Set up a new user named "user" with user ID 1000 (required for HF Spaces)
30
+ RUN useradd -m -u 1000 user
31
+
32
+ # Switch to the "user" user
33
+ USER user
34
+
35
+ # Set home to the user's home directory
36
+ ENV HOME=/home/user \
37
+ PATH=/home/user/.local/bin:$PATH
38
+
39
+ # Set the working directory to the user's home directory
40
+ WORKDIR $HOME/app
41
+
42
+ # Copy Python project files for dependency resolution
43
+ COPY --chown=user server/pyproject.toml server/uv.lock* ./server/
44
+
45
+ # Install dependencies first (better caching)
46
+ WORKDIR $HOME/app/server
47
+ RUN uv sync --no-install-project
48
+
49
+ # Copy the rest of the Python backend
50
+ COPY --chown=user server/ ./
51
+
52
+ # Install the project itself
53
+ RUN uv sync
54
+
55
+ # Copy built frontend from previous stage with proper ownership
56
+ COPY --chown=user --from=frontend-builder /app/demo/build $HOME/app/static-frontend
57
+
58
+ # Set working directory back to app root
59
+ WORKDIR $HOME/app
60
+
61
+ # Expose port 7860 (HF Spaces default)
62
+ EXPOSE 7860
63
+
64
+ # Start the FastAPI server (serves both frontend and backend)
65
+ CMD ["sh", "-c", "cd server && SERVE_FRONTEND=true uv run python launch_with_ui.py"]
README.md ADDED
@@ -0,0 +1,353 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: LeRobot Arena Transport Server
3
+ emoji: 🤖
4
+ colorFrom: blue
5
+ colorTo: purple
6
+ sdk: docker
7
+ app_port: 7860
8
+ dockerfile_path: services/transport-server/Dockerfile
9
+ suggested_hardware: cpu-upgrade
10
+ suggested_storage: small
11
+ short_description: Real-time robotics control
12
+ tags:
13
+ - robotics
14
+ - control
15
+ - websocket
16
+ - fastapi
17
+ - svelte
18
+ - real-time
19
+ - video-streaming
20
+ - transport-server
21
+ pinned: true
22
+ fullWidth: true
23
+ ---
24
+
25
+ # 🤖 LeRobot Arena Transport Server with UI
26
+
27
+ A complete Docker deployment of the LeRobot Arena Transport Server with integrated web UI. This combines the FastAPI backend with a SvelteKit frontend in a single container, inspired by the [LeRobot Arena Hugging Face Space](https://huggingface.co/spaces/blanchon/LeRobot-Arena).
28
+
29
+ ## 🚀 Quick Start with Docker
30
+
31
+ The easiest way to run the complete LeRobot Arena Transport Server is using Docker, which sets up both the frontend and backend automatically.
32
+
33
+ ### Prerequisites
34
+
35
+ - [Docker](https://www.docker.com/get-started) installed on your system
36
+ - [Docker Compose](https://docs.docker.com/compose/install/) (usually included with Docker Desktop)
37
+
38
+ ### Step-by-Step Instructions
39
+
40
+ 1. **Navigate to the transport server directory**
41
+ ```bash
42
+ cd services/transport-server
43
+ ```
44
+
45
+ 2. **Build the Docker image**
46
+ ```bash
47
+ docker build -t lerobot-arena-transport .
48
+ ```
49
+
50
+ 3. **Run the container**
51
+ ```bash
52
+ docker run -p 7860:7860 -e SERVE_FRONTEND=true lerobot-arena-transport
53
+ ```
54
+
55
+ 4. **Access the application**
56
+ - **Frontend**: http://localhost:7860
57
+ - **Backend API**: http://localhost:7860/api
58
+ - **API Documentation**: http://localhost:7860/api/docs
59
+
60
+ 5. **Stop the container**
61
+ ```bash
62
+ # Find the container ID
63
+ docker ps
64
+
65
+ # Stop the container
66
+ docker stop <container_id>
67
+ ```
68
+
69
+ ### Alternative Build & Run Options
70
+
71
+ **One-liner build and run:**
72
+ ```bash
73
+ docker build -t lerobot-arena-transport . && docker run -p 7860:7860 -e SERVE_FRONTEND=true lerobot-arena-transport
74
+ ```
75
+
76
+ **Run with custom environment variables:**
77
+ ```bash
78
+ docker run -p 8080:7860 \
79
+ -e SERVE_FRONTEND=true \
80
+ -e PORT=7860 \
81
+ -e HOST=0.0.0.0 \
82
+ lerobot-arena-transport
83
+ ```
84
+
85
+ **Run with volume mounts for logs:**
86
+ ```bash
87
+ docker run -p 7860:7860 \
88
+ -e SERVE_FRONTEND=true \
89
+ -v $(pwd)/logs:/home/user/app/logs \
90
+ lerobot-arena-transport
91
+ ```
92
+
93
+ ## 🛠️ Development Setup
94
+
95
+ For local development with hot-reload capabilities:
96
+
97
+ ### Backend Development
98
+
99
+ ```bash
100
+ # Navigate to server directory
101
+ cd server
102
+
103
+ # Install Python dependencies (using uv)
104
+ uv sync
105
+
106
+ # Start the backend server only
107
+ python api.py
108
+ ```
109
+
110
+ ### Frontend Development
111
+
112
+ ```bash
113
+ # Navigate to demo directory
114
+ cd demo
115
+
116
+ # Install dependencies
117
+ bun install
118
+
119
+ # Start the development server
120
+ bun run dev
121
+ ```
122
+
123
+ ### Client Library Development
124
+
125
+ ```bash
126
+ # Navigate to client library
127
+ cd client/js
128
+
129
+ # Install dependencies
130
+ bun install
131
+
132
+ # Build the client library
133
+ bun run build
134
+ ```
135
+
136
+ ## 📋 Project Structure
137
+
138
+ ```
139
+ services/transport-server/
140
+ ├── server/ # Python FastAPI backend
141
+ │ ├── src/ # Source code
142
+ │ ├── api.py # Main API application
143
+ │ ├── launch_with_ui.py # Combined launcher
144
+ │ └── pyproject.toml # Python dependencies
145
+ ├── demo/ # SvelteKit frontend
146
+ │ ├── src/ # Frontend source code
147
+ │ ├── package.json # Node.js dependencies
148
+ │ └── svelte.config.js # SvelteKit configuration
149
+ ├── client/js/ # TypeScript client library
150
+ │ ├── src/ # Client library source
151
+ │ └── package.json # Client dependencies
152
+ ├── Dockerfile # Docker configuration
153
+ ├── docker-compose.yml # Docker Compose setup
154
+ └── README.md # This file
155
+ ```
156
+
157
+ ## 🐳 Docker Information
158
+
159
+ The Docker setup includes:
160
+
161
+ - **Multi-stage build**: Optimized for production using Bun and uv
162
+ - **Client library build**: Builds the TypeScript client first
163
+ - **Frontend build**: Compiles SvelteKit app to static files
164
+ - **Backend integration**: FastAPI serves both API and static files
165
+ - **Port mapping**: Single port 7860 for both frontend and API
166
+ - **User permissions**: Properly configured for Hugging Face Spaces
167
+ - **Environment variables**: Configurable via environment
168
+
169
+ ### Environment Variables
170
+
171
+ - `SERVE_FRONTEND=true`: Enable frontend serving (default: false)
172
+ - `PORT=7860`: Port to run the server on (default: 7860)
173
+ - `HOST=0.0.0.0`: Host to bind to (default: 0.0.0.0)
174
+
175
+ ## 🌐 What's Included
176
+
177
+ ### Backend Features
178
+ - **Real-time Robot Control**: WebSocket-based communication
179
+ - **Video Streaming**: WebRTC video streaming capabilities
180
+ - **REST API**: Complete robotics control API
181
+ - **Room Management**: Create and manage robot control sessions
182
+ - **Health Monitoring**: Built-in health checks and logging
183
+
184
+ ### Frontend Features
185
+ - **Dashboard**: Server status and room overview
186
+ - **Robot Control**: 6-DOF robot arm control interface
187
+ - **Real-time Monitoring**: Live joint state visualization
188
+ - **Workspace Management**: Isolated environments for different sessions
189
+ - **Modern UI**: Responsive design with Tailwind CSS
190
+
191
+ ### Architecture
192
+ - **Frontend**: Svelte 5, TypeScript, Tailwind CSS
193
+ - **Backend**: FastAPI, Python 3.12, uvicorn
194
+ - **Client Library**: TypeScript with WebSocket support
195
+ - **Build System**: Bun for frontend, uv for Python
196
+ - **Container**: Multi-stage Docker build
197
+
198
+ ## 🔧 API Endpoints
199
+
200
+ ### Health Check
201
+ - `GET /health` - Server health status
202
+ - `GET /api/health` - API health status
203
+
204
+ ### Robotics API
205
+ - `GET /api/robotics/rooms` - List active rooms
206
+ - `POST /api/robotics/rooms` - Create new room
207
+ - `DELETE /api/robotics/rooms/{room_id}` - Delete room
208
+ - `WebSocket /api/robotics/ws/{room_id}` - Real-time control
209
+
210
+ ### Video API
211
+ - `GET /api/video/rooms` - List video rooms
212
+ - `WebSocket /api/video/ws/{room_id}` - Video streaming
213
+
214
+ ## 🧪 Testing the Setup
215
+
216
+ Run the included test script to verify everything works:
217
+
218
+ ```bash
219
+ ./test-docker.sh
220
+ ```
221
+
222
+ This script will build the image, start a container, test all endpoints, and clean up automatically.
223
+
224
+ ## 🚨 Troubleshooting
225
+
226
+ ### Port Conflicts
227
+ If port 7860 is already in use:
228
+
229
+ ```bash
230
+ # Check what's using the port
231
+ lsof -i :7860
232
+
233
+ # Use different port
234
+ docker run -p 8080:7860 -e SERVE_FRONTEND=true lerobot-arena-transport
235
+ ```
236
+
237
+ ### Container Issues
238
+
239
+ ```bash
240
+ # View logs
241
+ docker logs <container_id>
242
+
243
+ # Rebuild without cache
244
+ docker build --no-cache -t lerobot-arena-transport .
245
+
246
+ # Run with verbose logging
247
+ docker run -p 7860:7860 -e SERVE_FRONTEND=true -e LOG_LEVEL=debug lerobot-arena-transport
248
+ ```
249
+
250
+ ### Development Issues
251
+
252
+ ```bash
253
+ # Clear node modules and reinstall (for local development)
254
+ cd demo
255
+ rm -rf node_modules
256
+ bun install
257
+
258
+ # Clear SvelteKit cache
259
+ rm -rf .svelte-kit
260
+ bun run dev
261
+
262
+ # Re-link client library (if needed for local development)
263
+ cd ../client/js
264
+ bun link
265
+ cd ../../demo
266
+ bun link lerobot-arena-client
267
+ ```
268
+
269
+ ### Client Library Issues
270
+
271
+ ```bash
272
+ # Rebuild client library
273
+ cd client/js
274
+ bun run clean
275
+ bun run build
276
+ ```
277
+
278
+ ## 🚀 Hugging Face Spaces Deployment
279
+
280
+ This project is configured for deployment on Hugging Face Spaces:
281
+
282
+ 1. **Fork** this repository to your GitHub account
283
+ 2. **Create a new Space** on Hugging Face Spaces
284
+ 3. **Connect** your GitHub repository
285
+ 4. **Select Docker SDK** (should be auto-detected)
286
+ 5. **Set the Dockerfile path** to `services/transport-server/Dockerfile`
287
+ 6. **Deploy**
288
+
289
+ The Space will automatically build and run both the frontend and backend.
290
+
291
+ ### Hugging Face Spaces Configuration
292
+
293
+ Add this to your Space's README.md frontmatter:
294
+
295
+ ```yaml
296
+ ---
297
+ title: LeRobot Arena Transport Server
298
+ emoji: 🤖
299
+ colorFrom: blue
300
+ colorTo: purple
301
+ sdk: docker
302
+ app_port: 7860
303
+ dockerfile_path: services/transport-server/Dockerfile
304
+ suggested_hardware: cpu-upgrade
305
+ suggested_storage: small
306
+ short_description: Real-time robotics control and video streaming
307
+ tags:
308
+ - robotics
309
+ - control
310
+ - websocket
311
+ - fastapi
312
+ - svelte
313
+ pinned: true
314
+ fullWidth: true
315
+ ---
316
+ ```
317
+
318
+ ## 🎯 Use Cases
319
+
320
+ ### Development & Testing
321
+ - **API Development**: Test robotics control APIs
322
+ - **Frontend Development**: Develop robotics UIs
323
+ - **Integration Testing**: Test real-time communication
324
+
325
+ ### Production Deployment
326
+ - **Robot Control**: Remote robot operation
327
+ - **Multi-user**: Multiple operators on same robot
328
+ - **Monitoring**: Real-time robot state monitoring
329
+
330
+ ### Education & Demos
331
+ - **Learning**: Robotics programming education
332
+ - **Demonstrations**: Showcase robotics capabilities
333
+ - **Prototyping**: Rapid robotics application development
334
+
335
+ ## 🤝 Contributing
336
+
337
+ 1. **Fork** the repository
338
+ 2. **Create feature branch** (`git checkout -b feature/amazing-feature`)
339
+ 3. **Make changes** and add tests
340
+ 4. **Test with Docker** (`./test-docker.sh`)
341
+ 5. **Commit changes** (`git commit -m 'Add amazing feature'`)
342
+ 6. **Push to branch** (`git push origin feature/amazing-feature`)
343
+ 7. **Open Pull Request**
344
+
345
+ ## 📄 License
346
+
347
+ This project is licensed under the MIT License - see the LICENSE file for details.
348
+
349
+ ---
350
+
351
+ **Built with ❤️ for the robotics community** 🤖
352
+
353
+ For more information, visit the [main LeRobot Arena project](https://github.com/lerobot-arena/lerobot-arena).
client/js/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ description: Use Bun instead of Node.js, npm, pnpm, or vite.
3
+ globs: *.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json
4
+ alwaysApply: false
5
+ ---
6
+
7
+ Default to using Bun instead of Node.js.
8
+
9
+ - Use `bun <file>` instead of `node <file>` or `ts-node <file>`
10
+ - Use `bun test` instead of `jest` or `vitest`
11
+ - Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
12
+ - Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
13
+ - Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
14
+ - Bun automatically loads .env, so don't use dotenv.
15
+
16
+ ## APIs
17
+
18
+ - `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
19
+ - `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
20
+ - `Bun.redis` for Redis. Don't use `ioredis`.
21
+ - `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
22
+ - `WebSocket` is built-in. Don't use `ws`.
23
+ - Bun.$`ls` instead of execa.
24
+
25
+ ## Frontend
26
+
27
+ Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
28
+
29
+ Server:
30
+
31
+ ```ts#index.ts
32
+ import index from "./index.html"
33
+
34
+ Bun.serve({
35
+ routes: {
36
+ "/": index,
37
+ "/api/users/:id": {
38
+ GET: (req) => {
39
+ return new Response(JSON.stringify({ id: req.params.id }));
40
+ },
41
+ },
42
+ },
43
+ // optional websocket support
44
+ websocket: {
45
+ open: (ws) => {
46
+ ws.send("Hello, world!");
47
+ },
48
+ message: (ws, message) => {
49
+ ws.send(message);
50
+ },
51
+ close: (ws) => {
52
+ // handle close
53
+ }
54
+ },
55
+ development: {
56
+ hmr: true,
57
+ console: true,
58
+ }
59
+ })
60
+ ```
61
+
62
+ HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
63
+
64
+ ```html#index.html
65
+ <html>
66
+ <body>
67
+ <h1>Hello, world!</h1>
68
+ <script type="module" src="./frontend.tsx"></script>
69
+ </body>
70
+ </html>
71
+ ```
72
+
73
+ With the following `frontend.tsx`:
74
+
75
+ ```tsx#frontend.tsx
76
+ import React from "react";
77
+
78
+ // import .css files directly and it works
79
+ import './index.css';
80
+
81
+ import { createRoot } from "react-dom/client";
82
+
83
+ const root = createRoot(document.body);
84
+
85
+ export default function Frontend() {
86
+ return <h1>Hello, world!</h1>;
87
+ }
88
+
89
+ root.render(<Frontend />);
90
+ ```
91
+
92
+ Then, run index.ts
93
+
94
+ ```sh
95
+ bun --hot ./index.ts
96
+ ```
97
+
98
+ For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`.
client/js/.gitignore ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # dependencies (bun install)
2
+ node_modules
3
+
4
+ # output
5
+ out
6
+ dist
7
+ *.tgz
8
+
9
+ # code coverage
10
+ coverage
11
+ *.lcov
12
+
13
+ # logs
14
+ logs
15
+ _.log
16
+ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
17
+
18
+ # dotenv environment variable files
19
+ .env
20
+ .env.development.local
21
+ .env.test.local
22
+ .env.production.local
23
+ .env.local
24
+
25
+ # caches
26
+ .eslintcache
27
+ .cache
28
+ *.tsbuildinfo
29
+
30
+ # IntelliJ based IDEs
31
+ .idea
32
+
33
+ # Finder (MacOS) folder config
34
+ .DS_Store
client/js/README.md ADDED
@@ -0,0 +1,396 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # LeRobot Arena JavaScript/TypeScript Client
2
+
3
+ A modern TypeScript/JavaScript client library for LeRobot Arena robotics system, providing real-time communication for robot control and monitoring.
4
+
5
+ ## Features
6
+
7
+ - 🤖 **Producer/Consumer Pattern**: Control robots as producer, monitor as consumer
8
+ - 🔄 **Real-time Communication**: WebSocket-based bidirectional communication
9
+ - 📡 **REST API Support**: Complete CRUD operations for rooms and state
10
+ - 🎯 **Type Safety**: Full TypeScript support with comprehensive type definitions
11
+ - 🚨 **Safety Features**: Emergency stop functionality built-in
12
+ - 🔧 **Modular Design**: Import only what you need
13
+ - 🧪 **Well Tested**: Comprehensive test suite with Bun test
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ # Install the package (when published)
19
+ npm install lerobot-arena-client
20
+
21
+ # Or for local development
22
+ git clone <repository>
23
+ cd client/js
24
+ bun install
25
+ bun run build
26
+ ```
27
+
28
+ ## Quick Start
29
+
30
+ ### Producer (Robot Controller)
31
+
32
+ ```typescript
33
+ import { RoboticsProducer, createProducerClient } from 'lerobot-arena-client';
34
+
35
+ // Method 1: Manual setup
36
+ const producer = new RoboticsProducer('http://localhost:8000');
37
+ const roomId = await producer.createRoom();
38
+ await producer.connect(roomId);
39
+
40
+ // Method 2: Factory function (recommended)
41
+ const producer = await createProducerClient('http://localhost:8000');
42
+
43
+ // Send robot commands
44
+ await producer.sendJointUpdate([
45
+ { name: 'shoulder', value: 45.0 },
46
+ { name: 'elbow', value: -30.0 }
47
+ ]);
48
+
49
+ // Send complete state
50
+ await producer.sendStateSync({
51
+ base: 0.0,
52
+ shoulder: 45.0,
53
+ elbow: -30.0,
54
+ wrist: 0.0
55
+ });
56
+
57
+ // Emergency stop
58
+ await producer.sendEmergencyStop('Safety stop triggered');
59
+ ```
60
+
61
+ ### Consumer (Robot Monitor)
62
+
63
+ ```typescript
64
+ import { RoboticsConsumer, createConsumerClient } from 'lerobot-arena-client';
65
+
66
+ // Connect to existing room
67
+ const consumer = await createConsumerClient(roomId, 'http://localhost:8000');
68
+
69
+ // Set up event listeners
70
+ consumer.onJointUpdate((joints) => {
71
+ console.log('Joints updated:', joints);
72
+ });
73
+
74
+ consumer.onStateSync((state) => {
75
+ console.log('State synced:', state);
76
+ });
77
+
78
+ consumer.onError((error) => {
79
+ console.error('Error:', error);
80
+ });
81
+
82
+ // Get current state
83
+ const currentState = await consumer.getStateSyncAsync();
84
+ ```
85
+
86
+ ## API Reference
87
+
88
+ ### Core Classes
89
+
90
+ #### `RoboticsClientCore`
91
+ Base class providing common functionality:
92
+
93
+ ```typescript
94
+ // REST API methods
95
+ await client.listRooms();
96
+ await client.createRoom(roomId?);
97
+ await client.deleteRoom(roomId);
98
+ await client.getRoomInfo(roomId);
99
+ await client.getRoomState(roomId);
100
+
101
+ // Connection management
102
+ await client.connectToRoom(roomId, role, participantId?);
103
+ await client.disconnect();
104
+ client.isConnected();
105
+ client.getConnectionInfo();
106
+
107
+ // Utility
108
+ await client.sendHeartbeat();
109
+ ```
110
+
111
+ #### `RoboticsProducer`
112
+ Producer-specific functionality:
113
+
114
+ ```typescript
115
+ const producer = new RoboticsProducer('http://localhost:8000');
116
+
117
+ // Connection
118
+ await producer.connect(roomId, participantId?);
119
+
120
+ // Commands
121
+ await producer.sendJointUpdate(joints);
122
+ await producer.sendStateSync(state);
123
+ await producer.sendEmergencyStop(reason?);
124
+
125
+ // Static factory
126
+ const producer = await RoboticsProducer.createAndConnect(baseUrl, roomId?, participantId?);
127
+ ```
128
+
129
+ #### `RoboticsConsumer`
130
+ Consumer-specific functionality:
131
+
132
+ ```typescript
133
+ const consumer = new RoboticsConsumer('http://localhost:8000');
134
+
135
+ // Connection
136
+ await consumer.connect(roomId, participantId?);
137
+
138
+ // Data access
139
+ await consumer.getStateSyncAsync();
140
+
141
+ // Event callbacks
142
+ consumer.onJointUpdate(callback);
143
+ consumer.onStateSync(callback);
144
+ consumer.onError(callback);
145
+ consumer.onConnected(callback);
146
+ consumer.onDisconnected(callback);
147
+
148
+ // Static factory
149
+ const consumer = await RoboticsConsumer.createAndConnect(roomId, baseUrl, participantId?);
150
+ ```
151
+
152
+ ### Factory Functions
153
+
154
+ ```typescript
155
+ import { createClient, createProducerClient, createConsumerClient } from 'lerobot-arena-client';
156
+
157
+ // Generic factory
158
+ const client = createClient('producer', 'http://localhost:8000');
159
+
160
+ // Specialized factories (auto-connect)
161
+ const producer = await createProducerClient('http://localhost:8000', roomId?, participantId?);
162
+ const consumer = await createConsumerClient(roomId, 'http://localhost:8000', participantId?);
163
+ ```
164
+
165
+ ### Type Definitions
166
+
167
+ ```typescript
168
+ interface JointData {
169
+ name: string;
170
+ value: number;
171
+ speed?: number;
172
+ }
173
+
174
+ interface RoomInfo {
175
+ id: string;
176
+ participants: {
177
+ producer: string | null;
178
+ consumers: string[];
179
+ total: number;
180
+ };
181
+ joints_count: number;
182
+ has_producer?: boolean;
183
+ active_consumers?: number;
184
+ }
185
+
186
+ interface RoomState {
187
+ room_id: string;
188
+ joints: Record<string, number>;
189
+ participants: {
190
+ producer: string | null;
191
+ consumers: string[];
192
+ total: number;
193
+ };
194
+ timestamp: string;
195
+ }
196
+
197
+ type ParticipantRole = 'producer' | 'consumer';
198
+ type MessageType = 'joint_update' | 'state_sync' | 'heartbeat' | 'emergency_stop' | 'joined' | 'error';
199
+ ```
200
+
201
+ ## Examples
202
+
203
+ The `examples/` directory contains complete working examples:
204
+
205
+ ### Running Examples
206
+
207
+ ```bash
208
+ # Build the library first
209
+ bun run build
210
+
211
+ # Run producer example
212
+ node examples/basic-producer.js
213
+
214
+ # Run consumer example (in another terminal)
215
+ node examples/basic-consumer.js
216
+ ```
217
+
218
+ ### Example Files
219
+
220
+ - **`basic-producer.js`**: Complete producer workflow
221
+ - **`basic-consumer.js`**: Interactive consumer example
222
+ - **`room-management.js`**: REST API operations
223
+ - **`producer-consumer-demo.js`**: Full integration demo
224
+
225
+ ## Development
226
+
227
+ ### Prerequisites
228
+
229
+ - [Bun](https://bun.sh/) >= 1.0.0
230
+ - LeRobot Arena server running on `http://localhost:8000`
231
+
232
+ ### Setup
233
+
234
+ ```bash
235
+ # Clone and install
236
+ git clone <repository>
237
+ cd client/js
238
+ bun install
239
+
240
+ # Development build (watch mode)
241
+ bun run dev
242
+
243
+ # Production build
244
+ bun run build
245
+
246
+ # Run tests
247
+ bun test
248
+
249
+ # Type checking
250
+ bun run typecheck
251
+
252
+ # Linting
253
+ bun run lint
254
+ bun run lint:fix
255
+ ```
256
+
257
+ ### Testing
258
+
259
+ The library includes comprehensive tests:
260
+
261
+ ```bash
262
+ # Run all tests
263
+ bun test
264
+
265
+ # Run specific test files
266
+ bun test tests/producer.test.ts
267
+ bun test tests/consumer.test.ts
268
+ bun test tests/integration.test.ts
269
+ bun test tests/rest-api.test.ts
270
+
271
+ # Run tests with coverage
272
+ bun test --coverage
273
+ ```
274
+
275
+ ### Project Structure
276
+
277
+ ```
278
+ client/js/
279
+ ├── src/
280
+ │ ├── index.ts # Main entry point
281
+ │ ├── robotics/
282
+ │ │ ├── index.ts # Robotics module exports
283
+ │ │ ├── types.ts # TypeScript type definitions
284
+ │ │ ├── core.ts # Base client class
285
+ │ │ ├── producer.ts # Producer client
286
+ │ │ ├── consumer.ts # Consumer client
287
+ │ │ └── factory.ts # Factory functions
288
+ │ ├── video/ # Video module (placeholder)
289
+ │ └── audio/ # Audio module (placeholder)
290
+ ├── tests/
291
+ │ ├── producer.test.ts # Producer tests
292
+ │ ├── consumer.test.ts # Consumer tests
293
+ │ ├── integration.test.ts # Integration tests
294
+ │ └── rest-api.test.ts # REST API tests
295
+ ├── examples/
296
+ │ ├── basic-producer.js # Producer example
297
+ │ ├── basic-consumer.js # Consumer example
298
+ │ └── README.md # Examples documentation
299
+ ├── dist/ # Built output
300
+ ├── package.json
301
+ ├── tsconfig.json
302
+ ├── vite.config.ts
303
+ └── README.md
304
+ ```
305
+
306
+ ## Error Handling
307
+
308
+ The client provides comprehensive error handling:
309
+
310
+ ```typescript
311
+ // Connection errors
312
+ try {
313
+ await producer.connect(roomId);
314
+ } catch (error) {
315
+ console.error('Connection failed:', error.message);
316
+ }
317
+
318
+ // Operation errors
319
+ producer.onError((error) => {
320
+ console.error('Producer error:', error);
321
+ });
322
+
323
+ // Network timeouts
324
+ const options = { timeout: 10000 }; // 10 seconds
325
+ const client = new RoboticsProducer('http://localhost:8000', options);
326
+ ```
327
+
328
+ ## Configuration
329
+
330
+ ### Client Options
331
+
332
+ ```typescript
333
+ interface ClientOptions {
334
+ timeout?: number; // Request timeout (default: 5000ms)
335
+ reconnect_attempts?: number; // Auto-reconnect attempts (default: 3)
336
+ heartbeat_interval?: number; // Heartbeat interval (default: 30000ms)
337
+ }
338
+
339
+ const producer = new RoboticsProducer('http://localhost:8000', {
340
+ timeout: 10000,
341
+ reconnect_attempts: 5,
342
+ heartbeat_interval: 15000
343
+ });
344
+ ```
345
+
346
+ ## Troubleshooting
347
+
348
+ ### Common Issues
349
+
350
+ 1. **Connection Failed**: Ensure the server is running on `http://localhost:8000`
351
+ 2. **Import Errors**: Make sure you've built the library (`bun run build`)
352
+ 3. **Room Not Found**: Check that the room ID exists
353
+ 4. **Permission Denied**: Only one producer per room is allowed
354
+ 5. **WebSocket Errors**: Check firewall settings and network connectivity
355
+
356
+ ### Debug Mode
357
+
358
+ Enable detailed logging:
359
+
360
+ ```typescript
361
+ // Set up detailed error handling
362
+ producer.onError((error) => {
363
+ console.error('Detailed error:', error);
364
+ });
365
+
366
+ // Monitor connection events
367
+ producer.onConnected(() => console.log('Connected'));
368
+ producer.onDisconnected(() => console.log('Disconnected'));
369
+ ```
370
+
371
+ ### Performance Tips
372
+
373
+ - Use the factory functions for simpler setup
374
+ - Batch joint updates when possible
375
+ - Monitor connection state before sending commands
376
+ - Implement proper cleanup in your applications
377
+
378
+ ## Contributing
379
+
380
+ 1. Fork the repository
381
+ 2. Create a feature branch: `git checkout -b feature/amazing-feature`
382
+ 3. Make your changes and add tests
383
+ 4. Run the test suite: `bun test`
384
+ 5. Commit your changes: `git commit -m 'Add amazing feature'`
385
+ 6. Push to the branch: `git push origin feature/amazing-feature`
386
+ 7. Open a Pull Request
387
+
388
+ ## License
389
+
390
+ MIT License - see LICENSE file for details
391
+
392
+ ## Support
393
+
394
+ - 📚 [Documentation](./examples/README.md)
395
+ - 🐛 [Issue Tracker](https://github.com/lerobot-arena/lerobot-arena/issues)
396
+ - 💬 [Discussions](https://github.com/lerobot-arena/lerobot-arena/discussions)
client/js/bun.lock ADDED
@@ -0,0 +1,409 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "lockfileVersion": 1,
3
+ "workspaces": {
4
+ "": {
5
+ "name": "lerobot_arena_client",
6
+ "dependencies": {
7
+ "@hey-api/client-fetch": "^0.2.1",
8
+ "eventemitter3": "^5.0.1",
9
+ },
10
+ "devDependencies": {
11
+ "@hey-api/openapi-ts": "^0.53.8",
12
+ "@types/bun": "^1.2.15",
13
+ "typescript": "^5.3.3",
14
+ "vite": "6.3.5",
15
+ "vite-plugin-dts": "4.5.4",
16
+ },
17
+ "peerDependencies": {
18
+ "typescript": ">=5.0.0",
19
+ },
20
+ },
21
+ },
22
+ "packages": {
23
+ "@apidevtools/json-schema-ref-parser": ["@apidevtools/json-schema-ref-parser@11.7.2", "", { "dependencies": { "@jsdevtools/ono": "^7.1.3", "@types/json-schema": "^7.0.15", "js-yaml": "^4.1.0" } }, "sha512-4gY54eEGEstClvEkGnwVkTkrx0sqwemEFG5OSRRn3tD91XH0+Q8XIkYIfo7IwEWPpJZwILb9GUXeShtplRc/eA=="],
24
+
25
+ "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
26
+
27
+ "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="],
28
+
29
+ "@babel/parser": ["@babel/parser@7.27.5", "", { "dependencies": { "@babel/types": "^7.27.3" }, "bin": "./bin/babel-parser.js" }, "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg=="],
30
+
31
+ "@babel/types": ["@babel/types@7.27.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q=="],
32
+
33
+ "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA=="],
34
+
35
+ "@esbuild/android-arm": ["@esbuild/android-arm@0.25.5", "", { "os": "android", "cpu": "arm" }, "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA=="],
36
+
37
+ "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.5", "", { "os": "android", "cpu": "arm64" }, "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg=="],
38
+
39
+ "@esbuild/android-x64": ["@esbuild/android-x64@0.25.5", "", { "os": "android", "cpu": "x64" }, "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw=="],
40
+
41
+ "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ=="],
42
+
43
+ "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ=="],
44
+
45
+ "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw=="],
46
+
47
+ "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw=="],
48
+
49
+ "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.5", "", { "os": "linux", "cpu": "arm" }, "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw=="],
50
+
51
+ "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg=="],
52
+
53
+ "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA=="],
54
+
55
+ "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.5", "", { "os": "linux", "cpu": "none" }, "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg=="],
56
+
57
+ "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.5", "", { "os": "linux", "cpu": "none" }, "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg=="],
58
+
59
+ "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ=="],
60
+
61
+ "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.5", "", { "os": "linux", "cpu": "none" }, "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA=="],
62
+
63
+ "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ=="],
64
+
65
+ "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.5", "", { "os": "linux", "cpu": "x64" }, "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw=="],
66
+
67
+ "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.5", "", { "os": "none", "cpu": "arm64" }, "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw=="],
68
+
69
+ "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.5", "", { "os": "none", "cpu": "x64" }, "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ=="],
70
+
71
+ "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.5", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw=="],
72
+
73
+ "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg=="],
74
+
75
+ "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA=="],
76
+
77
+ "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw=="],
78
+
79
+ "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ=="],
80
+
81
+ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.5", "", { "os": "win32", "cpu": "x64" }, "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g=="],
82
+
83
+ "@hey-api/client-fetch": ["@hey-api/client-fetch@0.2.4", "", {}, "sha512-SGTVAVw3PlKDLw+IyhNhb/jCH3P1P2xJzLxA8Kyz1g95HrkYOJdRpl9F5I7LLwo9aCIB7nwR2NrSeX7QaQD7vQ=="],
84
+
85
+ "@hey-api/openapi-ts": ["@hey-api/openapi-ts@0.53.12", "", { "dependencies": { "@apidevtools/json-schema-ref-parser": "11.7.2", "c12": "2.0.1", "commander": "12.1.0", "handlebars": "4.7.8" }, "peerDependencies": { "typescript": "^5.x" }, "bin": { "openapi-ts": "bin/index.cjs" } }, "sha512-cOm8AlUqJIWdLXq+Pk4mTXhEApRSc9xEWTVT8MZAyEqrN1Yhiisl2wyZGH9quzKpolq+oqvgcx61txtwHwi8vQ=="],
86
+
87
+ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
88
+
89
+ "@jsdevtools/ono": ["@jsdevtools/ono@7.1.3", "", {}, "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="],
90
+
91
+ "@microsoft/api-extractor": ["@microsoft/api-extractor@7.52.8", "", { "dependencies": { "@microsoft/api-extractor-model": "7.30.6", "@microsoft/tsdoc": "~0.15.1", "@microsoft/tsdoc-config": "~0.17.1", "@rushstack/node-core-library": "5.13.1", "@rushstack/rig-package": "0.5.3", "@rushstack/terminal": "0.15.3", "@rushstack/ts-command-line": "5.0.1", "lodash": "~4.17.15", "minimatch": "~3.0.3", "resolve": "~1.22.1", "semver": "~7.5.4", "source-map": "~0.6.1", "typescript": "5.8.2" }, "bin": { "api-extractor": "bin/api-extractor" } }, "sha512-cszYIcjiNscDoMB1CIKZ3My61+JOhpERGlGr54i6bocvGLrcL/wo9o+RNXMBrb7XgLtKaizZWUpqRduQuHQLdg=="],
92
+
93
+ "@microsoft/api-extractor-model": ["@microsoft/api-extractor-model@7.30.6", "", { "dependencies": { "@microsoft/tsdoc": "~0.15.1", "@microsoft/tsdoc-config": "~0.17.1", "@rushstack/node-core-library": "5.13.1" } }, "sha512-znmFn69wf/AIrwHya3fxX6uB5etSIn6vg4Q4RB/tb5VDDs1rqREc+AvMC/p19MUN13CZ7+V/8pkYPTj7q8tftg=="],
94
+
95
+ "@microsoft/tsdoc": ["@microsoft/tsdoc@0.15.1", "", {}, "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw=="],
96
+
97
+ "@microsoft/tsdoc-config": ["@microsoft/tsdoc-config@0.17.1", "", { "dependencies": { "@microsoft/tsdoc": "0.15.1", "ajv": "~8.12.0", "jju": "~1.4.0", "resolve": "~1.22.2" } }, "sha512-UtjIFe0C6oYgTnad4q1QP4qXwLhe6tIpNTRStJ2RZEPIkqQPREAwE5spzVxsdn9UaEMUqhh0AqSx3X4nWAKXWw=="],
98
+
99
+ "@rollup/pluginutils": ["@rollup/pluginutils@5.1.4", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ=="],
100
+
101
+ "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.41.1", "", { "os": "android", "cpu": "arm" }, "sha512-NELNvyEWZ6R9QMkiytB4/L4zSEaBC03KIXEghptLGLZWJ6VPrL63ooZQCOnlx36aQPGhzuOMwDerC1Eb2VmrLw=="],
102
+
103
+ "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.41.1", "", { "os": "android", "cpu": "arm64" }, "sha512-DXdQe1BJ6TK47ukAoZLehRHhfKnKg9BjnQYUu9gzhI8Mwa1d2fzxA1aw2JixHVl403bwp1+/o/NhhHtxWJBgEA=="],
104
+
105
+ "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.41.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5afxvwszzdulsU2w8JKWwY8/sJOLPzf0e1bFuvcW5h9zsEg+RQAojdW0ux2zyYAz7R8HvvzKCjLNJhVq965U7w=="],
106
+
107
+ "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.41.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-egpJACny8QOdHNNMZKf8xY0Is6gIMz+tuqXlusxquWu3F833DcMwmGM7WlvCO9sB3OsPjdC4U0wHw5FabzCGZg=="],
108
+
109
+ "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.41.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-DBVMZH5vbjgRk3r0OzgjS38z+atlupJ7xfKIDJdZZL6sM6wjfDNo64aowcLPKIx7LMQi8vybB56uh1Ftck/Atg=="],
110
+
111
+ "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.41.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-3FkydeohozEskBxNWEIbPfOE0aqQgB6ttTkJ159uWOFn42VLyfAiyD9UK5mhu+ItWzft60DycIN1Xdgiy8o/SA=="],
112
+
113
+ "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.41.1", "", { "os": "linux", "cpu": "arm" }, "sha512-wC53ZNDgt0pqx5xCAgNunkTzFE8GTgdZ9EwYGVcg+jEjJdZGtq9xPjDnFgfFozQI/Xm1mh+D9YlYtl+ueswNEg=="],
114
+
115
+ "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.41.1", "", { "os": "linux", "cpu": "arm" }, "sha512-jwKCca1gbZkZLhLRtsrka5N8sFAaxrGz/7wRJ8Wwvq3jug7toO21vWlViihG85ei7uJTpzbXZRcORotE+xyrLA=="],
116
+
117
+ "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.41.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-g0UBcNknsmmNQ8V2d/zD2P7WWfJKU0F1nu0k5pW4rvdb+BIqMm8ToluW/eeRmxCared5dD76lS04uL4UaNgpNA=="],
118
+
119
+ "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.41.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-XZpeGB5TKEZWzIrj7sXr+BEaSgo/ma/kCgrZgL0oo5qdB1JlTzIYQKel/RmhT6vMAvOdM2teYlAaOGJpJ9lahg=="],
120
+
121
+ "@rollup/rollup-linux-loongarch64-gnu": ["@rollup/rollup-linux-loongarch64-gnu@4.41.1", "", { "os": "linux", "cpu": "none" }, "sha512-bkCfDJ4qzWfFRCNt5RVV4DOw6KEgFTUZi2r2RuYhGWC8WhCA8lCAJhDeAmrM/fdiAH54m0mA0Vk2FGRPyzI+tw=="],
122
+
123
+ "@rollup/rollup-linux-powerpc64le-gnu": ["@rollup/rollup-linux-powerpc64le-gnu@4.41.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3mr3Xm+gvMX+/8EKogIZSIEF0WUu0HL9di+YWlJpO8CQBnoLAEL/roTCxuLncEdgcfJcvA4UMOf+2dnjl4Ut1A=="],
124
+
125
+ "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.41.1", "", { "os": "linux", "cpu": "none" }, "sha512-3rwCIh6MQ1LGrvKJitQjZFuQnT2wxfU+ivhNBzmxXTXPllewOF7JR1s2vMX/tWtUYFgphygxjqMl76q4aMotGw=="],
126
+
127
+ "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.41.1", "", { "os": "linux", "cpu": "none" }, "sha512-LdIUOb3gvfmpkgFZuccNa2uYiqtgZAz3PTzjuM5bH3nvuy9ty6RGc/Q0+HDFrHrizJGVpjnTZ1yS5TNNjFlklw=="],
128
+
129
+ "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.41.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-oIE6M8WC9ma6xYqjvPhzZYk6NbobIURvP/lEbh7FWplcMO6gn7MM2yHKA1eC/GvYwzNKK/1LYgqzdkZ8YFxR8g=="],
130
+
131
+ "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.41.1", "", { "os": "linux", "cpu": "x64" }, "sha512-cWBOvayNvA+SyeQMp79BHPK8ws6sHSsYnK5zDcsC3Hsxr1dgTABKjMnMslPq1DvZIp6uO7kIWhiGwaTdR4Og9A=="],
132
+
133
+ "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.41.1", "", { "os": "linux", "cpu": "x64" }, "sha512-y5CbN44M+pUCdGDlZFzGGBSKCA4A/J2ZH4edTYSSxFg7ce1Xt3GtydbVKWLlzL+INfFIZAEg1ZV6hh9+QQf9YQ=="],
134
+
135
+ "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.41.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lZkCxIrjlJlMt1dLO/FbpZbzt6J/A8p4DnqzSa4PWqPEUUUnzXLeki/iyPLfV0BmHItlYgHUqJe+3KiyydmiNQ=="],
136
+
137
+ "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.41.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-+psFT9+pIh2iuGsxFYYa/LhS5MFKmuivRsx9iPJWNSGbh2XVEjk90fmpUEjCnILPEPJnikAU6SFDiEUyOv90Pg=="],
138
+
139
+ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.41.1", "", { "os": "win32", "cpu": "x64" }, "sha512-Wq2zpapRYLfi4aKxf2Xff0tN+7slj2d4R87WEzqw7ZLsVvO5zwYCIuEGSZYiK41+GlwUo1HiR+GdkLEJnCKTCw=="],
140
+
141
+ "@rushstack/node-core-library": ["@rushstack/node-core-library@5.13.1", "", { "dependencies": { "ajv": "~8.13.0", "ajv-draft-04": "~1.0.0", "ajv-formats": "~3.0.1", "fs-extra": "~11.3.0", "import-lazy": "~4.0.0", "jju": "~1.4.0", "resolve": "~1.22.1", "semver": "~7.5.4" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-5yXhzPFGEkVc9Fu92wsNJ9jlvdwz4RNb2bMso+/+TH0nMm1jDDDsOIf4l8GAkPxGuwPw5DH24RliWVfSPhlW/Q=="],
142
+
143
+ "@rushstack/rig-package": ["@rushstack/rig-package@0.5.3", "", { "dependencies": { "resolve": "~1.22.1", "strip-json-comments": "~3.1.1" } }, "sha512-olzSSjYrvCNxUFZowevC3uz8gvKr3WTpHQ7BkpjtRpA3wK+T0ybep/SRUMfr195gBzJm5gaXw0ZMgjIyHqJUow=="],
144
+
145
+ "@rushstack/terminal": ["@rushstack/terminal@0.15.3", "", { "dependencies": { "@rushstack/node-core-library": "5.13.1", "supports-color": "~8.1.1" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-DGJ0B2Vm69468kZCJkPj3AH5nN+nR9SPmC0rFHtzsS4lBQ7/dgOwtwVxYP7W9JPDMuRBkJ4KHmWKr036eJsj9g=="],
146
+
147
+ "@rushstack/ts-command-line": ["@rushstack/ts-command-line@5.0.1", "", { "dependencies": { "@rushstack/terminal": "0.15.3", "@types/argparse": "1.0.38", "argparse": "~1.0.9", "string-argv": "~0.3.1" } }, "sha512-bsbUucn41UXrQK7wgM8CNM/jagBytEyJqXw/umtI8d68vFm1Jwxh1OtLrlW7uGZgjCWiiPH6ooUNa1aVsuVr3Q=="],
148
+
149
+ "@types/argparse": ["@types/argparse@1.0.38", "", {}, "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA=="],
150
+
151
+ "@types/bun": ["@types/bun@1.2.15", "", { "dependencies": { "bun-types": "1.2.15" } }, "sha512-U1ljPdBEphF0nw1MIk0hI7kPg7dFdPyM7EenHsp6W5loNHl7zqy6JQf/RKCgnUn2KDzUpkBwHPnEJEjII594bA=="],
152
+
153
+ "@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
154
+
155
+ "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
156
+
157
+ "@types/node": ["@types/node@22.15.30", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6Q7lr06bEHdlfplU6YRbgG1SFBdlsfNC4/lX+SkhiTs0cpJkOElmWls8PxDFv4yY/xKb8Y6SO0OmSX4wgqTZbA=="],
158
+
159
+ "@volar/language-core": ["@volar/language-core@2.4.14", "", { "dependencies": { "@volar/source-map": "2.4.14" } }, "sha512-X6beusV0DvuVseaOEy7GoagS4rYHgDHnTrdOj5jeUb49fW5ceQyP9Ej5rBhqgz2wJggl+2fDbbojq1XKaxDi6w=="],
160
+
161
+ "@volar/source-map": ["@volar/source-map@2.4.14", "", {}, "sha512-5TeKKMh7Sfxo8021cJfmBzcjfY1SsXsPMMjMvjY7ivesdnybqqS+GxGAoXHAOUawQTwtdUxgP65Im+dEmvWtYQ=="],
162
+
163
+ "@volar/typescript": ["@volar/typescript@2.4.14", "", { "dependencies": { "@volar/language-core": "2.4.14", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-p8Z6f/bZM3/HyCdRNFZOEEzts51uV8WHeN8Tnfnm2EBv6FDB2TQLzfVx7aJvnl8ofKAOnS64B2O8bImBFaauRw=="],
164
+
165
+ "@vue/compiler-core": ["@vue/compiler-core@3.5.16", "", { "dependencies": { "@babel/parser": "^7.27.2", "@vue/shared": "3.5.16", "entities": "^4.5.0", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-AOQS2eaQOaaZQoL1u+2rCJIKDruNXVBZSiUD3chnUrsoX5ZTQMaCvXlWNIfxBJuU15r1o7+mpo5223KVtIhAgQ=="],
166
+
167
+ "@vue/compiler-dom": ["@vue/compiler-dom@3.5.16", "", { "dependencies": { "@vue/compiler-core": "3.5.16", "@vue/shared": "3.5.16" } }, "sha512-SSJIhBr/teipXiXjmWOVWLnxjNGo65Oj/8wTEQz0nqwQeP75jWZ0n4sF24Zxoht1cuJoWopwj0J0exYwCJ0dCQ=="],
168
+
169
+ "@vue/compiler-vue2": ["@vue/compiler-vue2@2.7.16", "", { "dependencies": { "de-indent": "^1.0.2", "he": "^1.2.0" } }, "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A=="],
170
+
171
+ "@vue/language-core": ["@vue/language-core@2.2.0", "", { "dependencies": { "@volar/language-core": "~2.4.11", "@vue/compiler-dom": "^3.5.0", "@vue/compiler-vue2": "^2.7.16", "@vue/shared": "^3.5.0", "alien-signals": "^0.4.9", "minimatch": "^9.0.3", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-O1ZZFaaBGkKbsRfnVH1ifOK1/1BUkyK+3SQsfnh6PmMmD4qJcTU8godCeA96jjDRTL6zgnK7YzCHfaUlH2r0Mw=="],
172
+
173
+ "@vue/shared": ["@vue/shared@3.5.16", "", {}, "sha512-c/0fWy3Jw6Z8L9FmTyYfkpM5zklnqqa9+a6dz3DvONRKW2NEbh46BP0FHuLFSWi2TnQEtp91Z6zOWNrU6QiyPg=="],
174
+
175
+ "acorn": ["acorn@8.14.1", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg=="],
176
+
177
+ "ajv": ["ajv@8.12.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA=="],
178
+
179
+ "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="],
180
+
181
+ "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
182
+
183
+ "alien-signals": ["alien-signals@0.4.14", "", {}, "sha512-itUAVzhczTmP2U5yX67xVpsbbOiquusbWVyA9N+sy6+r6YVbFkahXvNCeEPWEOMhwDYwbVbGHFkVL03N9I5g+Q=="],
184
+
185
+ "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
186
+
187
+ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
188
+
189
+ "brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],
190
+
191
+ "bun-types": ["bun-types@1.2.15", "", { "dependencies": { "@types/node": "*" } }, "sha512-NarRIaS+iOaQU1JPfyKhZm4AsUOrwUOqRNHY0XxI8GI8jYxiLXLcdjYMG9UKS+fwWasc1uw1htV9AX24dD+p4w=="],
192
+
193
+ "c12": ["c12@2.0.1", "", { "dependencies": { "chokidar": "^4.0.1", "confbox": "^0.1.7", "defu": "^6.1.4", "dotenv": "^16.4.5", "giget": "^1.2.3", "jiti": "^2.3.0", "mlly": "^1.7.1", "ohash": "^1.1.4", "pathe": "^1.1.2", "perfect-debounce": "^1.0.0", "pkg-types": "^1.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-Z4JgsKXHG37C6PYUtIxCfLJZvo6FyhHJoClwwb9ftUkLpPSkuYqn6Tr+vnaN8hymm0kIbcg6Ey3kv/Q71k5w/A=="],
194
+
195
+ "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
196
+
197
+ "chownr": ["chownr@2.0.0", "", {}, "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="],
198
+
199
+ "citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
200
+
201
+ "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
202
+
203
+ "compare-versions": ["compare-versions@6.1.1", "", {}, "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg=="],
204
+
205
+ "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
206
+
207
+ "confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
208
+
209
+ "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
210
+
211
+ "de-indent": ["de-indent@1.0.2", "", {}, "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg=="],
212
+
213
+ "debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
214
+
215
+ "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
216
+
217
+ "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
218
+
219
+ "dotenv": ["dotenv@16.5.0", "", {}, "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg=="],
220
+
221
+ "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
222
+
223
+ "esbuild": ["esbuild@0.25.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.5", "@esbuild/android-arm": "0.25.5", "@esbuild/android-arm64": "0.25.5", "@esbuild/android-x64": "0.25.5", "@esbuild/darwin-arm64": "0.25.5", "@esbuild/darwin-x64": "0.25.5", "@esbuild/freebsd-arm64": "0.25.5", "@esbuild/freebsd-x64": "0.25.5", "@esbuild/linux-arm": "0.25.5", "@esbuild/linux-arm64": "0.25.5", "@esbuild/linux-ia32": "0.25.5", "@esbuild/linux-loong64": "0.25.5", "@esbuild/linux-mips64el": "0.25.5", "@esbuild/linux-ppc64": "0.25.5", "@esbuild/linux-riscv64": "0.25.5", "@esbuild/linux-s390x": "0.25.5", "@esbuild/linux-x64": "0.25.5", "@esbuild/netbsd-arm64": "0.25.5", "@esbuild/netbsd-x64": "0.25.5", "@esbuild/openbsd-arm64": "0.25.5", "@esbuild/openbsd-x64": "0.25.5", "@esbuild/sunos-x64": "0.25.5", "@esbuild/win32-arm64": "0.25.5", "@esbuild/win32-ia32": "0.25.5", "@esbuild/win32-x64": "0.25.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ=="],
224
+
225
+ "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
226
+
227
+ "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
228
+
229
+ "exsolve": ["exsolve@1.0.5", "", {}, "sha512-pz5dvkYYKQ1AHVrgOzBKWeP4u4FRb3a6DNK2ucr0OoNwYIU4QWsJ+NM36LLzORT+z845MzKHHhpXiUF5nvQoJg=="],
230
+
231
+ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
232
+
233
+ "fdir": ["fdir@6.4.5", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-4BG7puHpVsIYxZUbiUE3RqGloLaSSwzYie5jvasC4LWuBWzZawynvYouhjbQKw2JuIGYdm0DzIxl8iVidKlUEw=="],
234
+
235
+ "fs-extra": ["fs-extra@11.3.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew=="],
236
+
237
+ "fs-minipass": ["fs-minipass@2.1.0", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg=="],
238
+
239
+ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
240
+
241
+ "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
242
+
243
+ "giget": ["giget@1.2.5", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.5.4", "pathe": "^2.0.3", "tar": "^6.2.1" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-r1ekGw/Bgpi3HLV3h1MRBIlSAdHoIMklpaQ3OQLFcRw9PwAj2rqigvIbg+dBUI51OxVI2jsEtDywDBjSiuf7Ug=="],
244
+
245
+ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
246
+
247
+ "handlebars": ["handlebars@4.7.8", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ=="],
248
+
249
+ "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
250
+
251
+ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
252
+
253
+ "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
254
+
255
+ "import-lazy": ["import-lazy@4.0.0", "", {}, "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw=="],
256
+
257
+ "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
258
+
259
+ "jiti": ["jiti@2.4.2", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="],
260
+
261
+ "jju": ["jju@1.4.0", "", {}, "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA=="],
262
+
263
+ "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
264
+
265
+ "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
266
+
267
+ "jsonfile": ["jsonfile@6.1.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ=="],
268
+
269
+ "kolorist": ["kolorist@1.8.0", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="],
270
+
271
+ "local-pkg": ["local-pkg@1.1.1", "", { "dependencies": { "mlly": "^1.7.4", "pkg-types": "^2.0.1", "quansync": "^0.2.8" } }, "sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg=="],
272
+
273
+ "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
274
+
275
+ "lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
276
+
277
+ "magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="],
278
+
279
+ "minimatch": ["minimatch@3.0.8", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q=="],
280
+
281
+ "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
282
+
283
+ "minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="],
284
+
285
+ "minizlib": ["minizlib@2.1.2", "", { "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" } }, "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg=="],
286
+
287
+ "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="],
288
+
289
+ "mlly": ["mlly@1.7.4", "", { "dependencies": { "acorn": "^8.14.0", "pathe": "^2.0.1", "pkg-types": "^1.3.0", "ufo": "^1.5.4" } }, "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw=="],
290
+
291
+ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
292
+
293
+ "muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
294
+
295
+ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
296
+
297
+ "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="],
298
+
299
+ "node-fetch-native": ["node-fetch-native@1.6.6", "", {}, "sha512-8Mc2HhqPdlIfedsuZoc3yioPuzp6b+L5jRCRY1QzuWZh2EGJVQrGppC6V6cF0bLdbW0+O2YpqCA25aF/1lvipQ=="],
300
+
301
+ "nypm": ["nypm@0.5.4", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "tinyexec": "^0.3.2", "ufo": "^1.5.4" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-X0SNNrZiGU8/e/zAB7sCTtdxWTMSIO73q+xuKgglm2Yvzwlo8UoC5FNySQFCvl84uPaeADkqHUZUkWy4aH4xOA=="],
302
+
303
+ "ohash": ["ohash@1.1.6", "", {}, "sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg=="],
304
+
305
+ "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
306
+
307
+ "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
308
+
309
+ "pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
310
+
311
+ "perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
312
+
313
+ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
314
+
315
+ "picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
316
+
317
+ "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
318
+
319
+ "postcss": ["postcss@8.5.4", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-QSa9EBe+uwlGTFmHsPKokv3B/oEMQZxfqW0QqNCyhpa6mB1afzulwn8hihglqAb2pOw+BJgNlmXQ8la2VeHB7w=="],
320
+
321
+ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
322
+
323
+ "quansync": ["quansync@0.2.10", "", {}, "sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A=="],
324
+
325
+ "rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="],
326
+
327
+ "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
328
+
329
+ "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
330
+
331
+ "resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="],
332
+
333
+ "rollup": ["rollup@4.41.1", "", { "dependencies": { "@types/estree": "1.0.7" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.41.1", "@rollup/rollup-android-arm64": "4.41.1", "@rollup/rollup-darwin-arm64": "4.41.1", "@rollup/rollup-darwin-x64": "4.41.1", "@rollup/rollup-freebsd-arm64": "4.41.1", "@rollup/rollup-freebsd-x64": "4.41.1", "@rollup/rollup-linux-arm-gnueabihf": "4.41.1", "@rollup/rollup-linux-arm-musleabihf": "4.41.1", "@rollup/rollup-linux-arm64-gnu": "4.41.1", "@rollup/rollup-linux-arm64-musl": "4.41.1", "@rollup/rollup-linux-loongarch64-gnu": "4.41.1", "@rollup/rollup-linux-powerpc64le-gnu": "4.41.1", "@rollup/rollup-linux-riscv64-gnu": "4.41.1", "@rollup/rollup-linux-riscv64-musl": "4.41.1", "@rollup/rollup-linux-s390x-gnu": "4.41.1", "@rollup/rollup-linux-x64-gnu": "4.41.1", "@rollup/rollup-linux-x64-musl": "4.41.1", "@rollup/rollup-win32-arm64-msvc": "4.41.1", "@rollup/rollup-win32-ia32-msvc": "4.41.1", "@rollup/rollup-win32-x64-msvc": "4.41.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-cPmwD3FnFv8rKMBc1MxWCwVQFxwf1JEmSX3iQXrRVVG15zerAIXRjMFVWnd5Q5QvgKF7Aj+5ykXFhUl+QGnyOw=="],
334
+
335
+ "semver": ["semver@7.5.4", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA=="],
336
+
337
+ "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
338
+
339
+ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
340
+
341
+ "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
342
+
343
+ "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="],
344
+
345
+ "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
346
+
347
+ "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
348
+
349
+ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
350
+
351
+ "tar": ["tar@6.2.1", "", { "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" } }, "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A=="],
352
+
353
+ "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
354
+
355
+ "tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
356
+
357
+ "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
358
+
359
+ "ufo": ["ufo@1.6.1", "", {}, "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA=="],
360
+
361
+ "uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="],
362
+
363
+ "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
364
+
365
+ "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
366
+
367
+ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
368
+
369
+ "vite": ["vite@6.3.5", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ=="],
370
+
371
+ "vite-plugin-dts": ["vite-plugin-dts@4.5.4", "", { "dependencies": { "@microsoft/api-extractor": "^7.50.1", "@rollup/pluginutils": "^5.1.4", "@volar/typescript": "^2.4.11", "@vue/language-core": "2.2.0", "compare-versions": "^6.1.1", "debug": "^4.4.0", "kolorist": "^1.8.0", "local-pkg": "^1.0.0", "magic-string": "^0.30.17" }, "peerDependencies": { "typescript": "*", "vite": "*" }, "optionalPeers": ["vite"] }, "sha512-d4sOM8M/8z7vRXHHq/ebbblfaxENjogAAekcfcDCCwAyvGqnPrc7f4NZbvItS+g4WTgerW0xDwSz5qz11JT3vg=="],
372
+
373
+ "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
374
+
375
+ "wordwrap": ["wordwrap@1.0.0", "", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="],
376
+
377
+ "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
378
+
379
+ "@microsoft/api-extractor/typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="],
380
+
381
+ "@rushstack/node-core-library/ajv": ["ajv@8.13.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.4.1" } }, "sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA=="],
382
+
383
+ "@rushstack/ts-command-line/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
384
+
385
+ "@vue/language-core/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
386
+
387
+ "ajv-formats/ajv": ["ajv@8.13.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.4.1" } }, "sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA=="],
388
+
389
+ "fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
390
+
391
+ "giget/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
392
+
393
+ "local-pkg/pkg-types": ["pkg-types@2.1.0", "", { "dependencies": { "confbox": "^0.2.1", "exsolve": "^1.0.1", "pathe": "^2.0.3" } }, "sha512-wmJwA+8ihJixSoHKxZJRBQG1oY8Yr9pGLzRmSsNms0iNWyHHAlZCa7mmKiFR10YPZuz/2k169JiS/inOjBCZ2A=="],
394
+
395
+ "minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
396
+
397
+ "mlly/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
398
+
399
+ "nypm/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
400
+
401
+ "pkg-types/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
402
+
403
+ "@vue/language-core/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
404
+
405
+ "local-pkg/pkg-types/confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="],
406
+
407
+ "local-pkg/pkg-types/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
408
+ }
409
+ }
client/js/examples/basic-consumer.js ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Basic Consumer Example - LeRobot Arena
4
+ *
5
+ * This example demonstrates:
6
+ * - Connecting to an existing room as a consumer
7
+ * - Receiving joint updates and state sync
8
+ * - Setting up callbacks
9
+ * - Getting current state
10
+ */
11
+
12
+ import { RoboticsConsumer } from '../dist/robotics/index.js';
13
+ import { createInterface } from 'readline';
14
+
15
+ async function main() {
16
+ console.log('🤖 LeRobot Arena Basic Consumer Example 🤖');
17
+
18
+ // Get room ID from user
19
+ const rl = createInterface({
20
+ input: process.stdin,
21
+ output: process.stdout
22
+ });
23
+
24
+ const roomId = await new Promise((resolve) => {
25
+ rl.question('Enter room ID to connect to: ', (answer) => {
26
+ rl.close();
27
+ resolve(answer.trim());
28
+ });
29
+ });
30
+
31
+ if (!roomId) {
32
+ console.error('❌ Room ID is required!');
33
+ return;
34
+ }
35
+
36
+ // Create consumer client
37
+ const consumer = new RoboticsConsumer('http://localhost:8000');
38
+
39
+ // Track received updates
40
+ let updateCount = 0;
41
+ let stateCount = 0;
42
+ const receivedUpdates = [];
43
+ const receivedStates = [];
44
+
45
+ // Set up callbacks
46
+ consumer.onJointUpdate((joints) => {
47
+ updateCount++;
48
+ console.log(`📥 [${updateCount}] Joint update: ${joints.length} joints`);
49
+
50
+ // Show joint details
51
+ joints.forEach(joint => {
52
+ console.log(` ${joint.name}: ${joint.value}${joint.speed ? ` (speed: ${joint.speed})` : ''}`);
53
+ });
54
+
55
+ receivedUpdates.push(joints);
56
+ });
57
+
58
+ consumer.onStateSync((state) => {
59
+ stateCount++;
60
+ console.log(`📊 [${stateCount}] State sync: ${Object.keys(state).length} joints`);
61
+
62
+ // Show state details
63
+ Object.entries(state).forEach(([name, value]) => {
64
+ console.log(` ${name}: ${value}`);
65
+ });
66
+
67
+ receivedStates.push(state);
68
+ });
69
+
70
+ consumer.onError((errorMsg) => {
71
+ console.error('❌ Consumer error:', errorMsg);
72
+ });
73
+
74
+ consumer.onConnected(() => {
75
+ console.log('✅ Consumer connected!');
76
+ });
77
+
78
+ consumer.onDisconnected(() => {
79
+ console.log('👋 Consumer disconnected!');
80
+ });
81
+
82
+ try {
83
+ // Connect to the room
84
+ console.log(`\n🔌 Connecting to room ${roomId}...`);
85
+ const success = await consumer.connect(roomId, 'demo-consumer');
86
+
87
+ if (!success) {
88
+ console.error('❌ Failed to connect to room!');
89
+ console.log('💡 Make sure the room exists and the server is running');
90
+ return;
91
+ }
92
+
93
+ console.log(`✅ Connected to room ${roomId} as consumer`);
94
+
95
+ // Show connection info
96
+ const info = consumer.getConnectionInfo();
97
+ console.log('\n📊 Connection Info:');
98
+ console.log(` Room ID: ${info.room_id}`);
99
+ console.log(` Role: ${info.role}`);
100
+ console.log(` Participant ID: ${info.participant_id}`);
101
+
102
+ // Get initial state
103
+ console.log('\n📋 Getting initial state...');
104
+ const initialState = await consumer.getStateSyncAsync();
105
+ console.log('Initial state:', Object.keys(initialState).length, 'joints');
106
+
107
+ if (Object.keys(initialState).length > 0) {
108
+ Object.entries(initialState).forEach(([name, value]) => {
109
+ console.log(` ${name}: ${value}`);
110
+ });
111
+ } else {
112
+ console.log(' (Empty - no joints set yet)');
113
+ }
114
+
115
+ // Listen for updates
116
+ console.log('\n👂 Listening for updates for 60 seconds...');
117
+ console.log(' (Producer can send commands during this time)');
118
+
119
+ const startTime = Date.now();
120
+ const duration = 60000; // 60 seconds
121
+
122
+ // Show periodic status
123
+ const statusInterval = setInterval(() => {
124
+ const elapsed = Date.now() - startTime;
125
+ const remaining = Math.max(0, duration - elapsed);
126
+
127
+ if (remaining > 0) {
128
+ console.log(`\n📊 Status (${Math.floor(remaining / 1000)}s remaining):`);
129
+ console.log(` Updates received: ${updateCount}`);
130
+ console.log(` State syncs received: ${stateCount}`);
131
+ }
132
+ }, 10000); // Every 10 seconds
133
+
134
+ await new Promise(resolve => setTimeout(resolve, duration));
135
+ clearInterval(statusInterval);
136
+
137
+ // Show final summary
138
+ console.log('\n📊 Final Summary:');
139
+ console.log(` Total updates received: ${updateCount}`);
140
+ console.log(` Total state syncs received: ${stateCount}`);
141
+
142
+ // Get final state
143
+ const finalState = await consumer.getStateSyncAsync();
144
+ console.log('\n📋 Final state:');
145
+ if (Object.keys(finalState).length > 0) {
146
+ Object.entries(finalState).forEach(([name, value]) => {
147
+ console.log(` ${name}: ${value}`);
148
+ });
149
+ } else {
150
+ console.log(' (Empty)');
151
+ }
152
+
153
+ console.log('\n✅ Basic consumer example completed!');
154
+
155
+ } catch (error) {
156
+ console.error('❌ Error:', error.message);
157
+ } finally {
158
+ // Always disconnect
159
+ if (consumer.isConnected()) {
160
+ console.log('\n🧹 Disconnecting...');
161
+ await consumer.disconnect();
162
+ }
163
+ console.log('👋 Goodbye!');
164
+ }
165
+ }
166
+
167
+ // Handle Ctrl+C gracefully
168
+ process.on('SIGINT', () => {
169
+ console.log('\n\n👋 Received SIGINT, shutting down gracefully...');
170
+ process.exit(0);
171
+ });
172
+
173
+ main().catch(console.error);
client/js/examples/basic-producer.js ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Basic Producer Example - LeRobot Arena
4
+ *
5
+ * This example demonstrates:
6
+ * - Creating a room
7
+ * - Connecting as a producer
8
+ * - Sending joint updates and state sync
9
+ * - Basic error handling
10
+ */
11
+
12
+ import { RoboticsProducer } from '../dist/robotics/index.js';
13
+
14
+ async function main() {
15
+ console.log('🤖 LeRobot Arena Basic Producer Example 🤖');
16
+
17
+ // Create producer client
18
+ const producer = new RoboticsProducer('http://localhost:8000');
19
+
20
+ // Set up event callbacks
21
+ producer.onConnected(() => {
22
+ console.log('✅ Producer connected!');
23
+ });
24
+
25
+ producer.onDisconnected(() => {
26
+ console.log('👋 Producer disconnected!');
27
+ });
28
+
29
+ producer.onError((error) => {
30
+ console.error('❌ Producer error:', error);
31
+ });
32
+
33
+ try {
34
+ // Create a room
35
+ console.log('\n📦 Creating room...');
36
+ const roomId = await producer.createRoom();
37
+ console.log(`✅ Room created: ${roomId}`);
38
+
39
+ // Connect as producer
40
+ console.log('\n🔌 Connecting as producer...');
41
+ const success = await producer.connect(roomId, 'demo-producer');
42
+
43
+ if (!success) {
44
+ console.error('❌ Failed to connect as producer!');
45
+ return;
46
+ }
47
+
48
+ console.log(`✅ Connected to room ${roomId} as producer`);
49
+
50
+ // Show connection info
51
+ const info = producer.getConnectionInfo();
52
+ console.log('\n📊 Connection Info:');
53
+ console.log(` Room ID: ${info.room_id}`);
54
+ console.log(` Role: ${info.role}`);
55
+ console.log(` Participant ID: ${info.participant_id}`);
56
+
57
+ // Send initial state sync
58
+ console.log('\n📤 Sending initial state...');
59
+ await producer.sendStateSync({
60
+ base: 0.0,
61
+ shoulder: 0.0,
62
+ elbow: 0.0,
63
+ wrist: 0.0,
64
+ gripper: 0.0
65
+ });
66
+ console.log('✅ Initial state sent');
67
+
68
+ // Simulate robot movement
69
+ console.log('\n🤖 Simulating robot movement...');
70
+
71
+ const movements = [
72
+ { name: 'shoulder', value: 45.0, description: 'Raise shoulder' },
73
+ { name: 'elbow', value: -30.0, description: 'Bend elbow' },
74
+ { name: 'wrist', value: 15.0, description: 'Turn wrist' },
75
+ { name: 'gripper', value: 0.5, description: 'Close gripper' },
76
+ ];
77
+
78
+ for (let i = 0; i < movements.length; i++) {
79
+ const movement = movements[i];
80
+ console.log(` ${i + 1}. ${movement.description}: ${movement.value}°`);
81
+
82
+ await producer.sendJointUpdate([{
83
+ name: movement.name,
84
+ value: movement.value
85
+ }]);
86
+
87
+ // Wait between movements
88
+ await new Promise(resolve => setTimeout(resolve, 1000));
89
+ }
90
+
91
+ // Send combined update
92
+ console.log('\n📤 Sending combined update...');
93
+ await producer.sendJointUpdate([
94
+ { name: 'base', value: 90.0 },
95
+ { name: 'shoulder', value: 60.0 },
96
+ { name: 'elbow', value: -45.0 }
97
+ ]);
98
+ console.log('✅ Combined update sent');
99
+
100
+ // Send heartbeat
101
+ console.log('\n💓 Sending heartbeat...');
102
+ await producer.sendHeartbeat();
103
+ console.log('✅ Heartbeat sent');
104
+
105
+ // Demonstrate emergency stop
106
+ console.log('\n🚨 Testing emergency stop...');
107
+ await producer.sendEmergencyStop('Demo emergency stop - testing safety');
108
+ console.log('✅ Emergency stop sent');
109
+
110
+ console.log('\n✅ Basic producer example completed!');
111
+ console.log(`\n💡 Room ID: ${roomId}`);
112
+ console.log(' You can use this room ID with the consumer example');
113
+
114
+ // Keep running for a bit to allow consumers to connect
115
+ console.log('\n⏳ Keeping producer alive for 30 seconds...');
116
+ console.log(' (Consumers can connect during this time)');
117
+ await new Promise(resolve => setTimeout(resolve, 30000));
118
+
119
+ } catch (error) {
120
+ console.error('❌ Error:', error.message);
121
+ } finally {
122
+ // Always disconnect and cleanup
123
+ if (producer.isConnected()) {
124
+ console.log('\n🧹 Cleaning up...');
125
+ await producer.disconnect();
126
+
127
+ // Optionally delete the room
128
+ const roomId = producer.currentRoomId;
129
+ if (roomId) {
130
+ await producer.deleteRoom(roomId);
131
+ console.log(`🗑️ Deleted room ${roomId}`);
132
+ }
133
+ }
134
+ console.log('👋 Goodbye!');
135
+ }
136
+ }
137
+
138
+ // Handle Ctrl+C gracefully
139
+ process.on('SIGINT', () => {
140
+ console.log('\n\n👋 Received SIGINT, shutting down gracefully...');
141
+ process.exit(0);
142
+ });
143
+
144
+ main().catch(console.error);
client/js/package.json ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "lerobot-arena-client",
3
+ "version": "1.0.0",
4
+ "description": "Modular TypeScript client library for LeRobot Arena - video streaming, robotics control, and sensor data",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ },
13
+ "./video": {
14
+ "import": "./dist/video.js",
15
+ "types": "./dist/video.d.ts"
16
+ },
17
+ "./robotics": {
18
+ "import": "./dist/robotics.js",
19
+ "types": "./dist/robotics.d.ts"
20
+ },
21
+ "./audio": {
22
+ "import": "./dist/audio.js",
23
+ "types": "./dist/audio.d.ts"
24
+ }
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "README.md"
29
+ ],
30
+ "scripts": {
31
+ "build": "bun run vite build",
32
+ "dev": "bun run vite build --watch",
33
+ "test": "bun test",
34
+ "lint": "bun run eslint src --ext ts,tsx",
35
+ "lint:fix": "bun run eslint src --ext ts,tsx --fix",
36
+ "typecheck": "bun run tsc --noEmit",
37
+ "clean": "rm -rf dist",
38
+ "prebuild": "bun run clean"
39
+ },
40
+ "keywords": [
41
+ "lerobot",
42
+ "arena",
43
+ "webrtc",
44
+ "video-streaming",
45
+ "robotics",
46
+ "real-time",
47
+ "typescript",
48
+ "client-library"
49
+ ],
50
+ "author": "LeRobot Arena Team",
51
+ "license": "MIT",
52
+ "dependencies": {
53
+ "eventemitter3": "^5.0.1"
54
+ },
55
+ "devDependencies": {
56
+ "@types/bun": "^1.2.15",
57
+ "typescript": "^5.3.3",
58
+ "vite": "6.3.5",
59
+ "vite-plugin-dts": "4.5.4"
60
+ },
61
+ "peerDependencies": {
62
+ "typescript": ">=5.0.0"
63
+ },
64
+ "repository": {
65
+ "type": "git",
66
+ "url": "git+https://github.com/lerobot-arena/lerobot-arena.git",
67
+ "directory": "src-python-video/clients/js"
68
+ },
69
+ "bugs": {
70
+ "url": "https://github.com/lerobot-arena/lerobot-arena/issues"
71
+ },
72
+ "homepage": "https://github.com/lerobot-arena/lerobot-arena#readme",
73
+ "engines": {
74
+ "bun": ">=1.0.0"
75
+ },
76
+ "private": true
77
+ }
client/js/src/index.ts ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * LeRobot Arena Client Library - TypeScript/JavaScript
3
+ *
4
+ * Main entry point for the robotics client library.
5
+ */
6
+
7
+ // Re-export everything from robotics module
8
+ export * as robotics from './robotics/index.js';
9
+ export * as video from './video/index.js';
10
+
11
+ // Version info
12
+ export const VERSION = '1.0.0';
client/js/src/robotics/consumer.ts ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Consumer client for receiving robot commands in LeRobot Arena
3
+ */
4
+
5
+ import { RoboticsClientCore } from './core.js';
6
+ import type {
7
+ WebSocketMessage,
8
+ JointUpdateMessage,
9
+ StateSyncMessage,
10
+ ClientOptions,
11
+ JointUpdateCallback,
12
+ StateSyncCallback,
13
+ } from './types.js';
14
+
15
+ export class RoboticsConsumer extends RoboticsClientCore {
16
+ // Event callbacks
17
+ private onStateSyncCallback: StateSyncCallback | null = null;
18
+ private onJointUpdateCallback: JointUpdateCallback | null = null;
19
+
20
+ constructor(baseUrl = 'http://localhost:8000', options: ClientOptions = {}) {
21
+ super(baseUrl, options);
22
+ }
23
+
24
+ // ============= CONSUMER CONNECTION =============
25
+
26
+ async connect(workspaceId: string, roomId: string, participantId?: string): Promise<boolean> {
27
+ return this.connectToRoom(workspaceId, roomId, 'consumer', participantId);
28
+ }
29
+
30
+ // ============= CONSUMER METHODS =============
31
+
32
+ async getStateSyncAsync(): Promise<Record<string, number>> {
33
+ if (!this.workspaceId || !this.roomId) {
34
+ throw new Error('Must be connected to a room');
35
+ }
36
+
37
+ const state = await this.getRoomState(this.workspaceId, this.roomId);
38
+ return state.joints;
39
+ }
40
+
41
+ // ============= EVENT CALLBACKS =============
42
+
43
+ onStateSync(callback: StateSyncCallback): void {
44
+ this.onStateSyncCallback = callback;
45
+ }
46
+
47
+ onJointUpdate(callback: JointUpdateCallback): void {
48
+ this.onJointUpdateCallback = callback;
49
+ }
50
+
51
+ // ============= MESSAGE HANDLING =============
52
+
53
+ protected override handleRoleSpecificMessage(message: WebSocketMessage): void {
54
+ switch (message.type) {
55
+ case 'state_sync':
56
+ this.handleStateSync(message as StateSyncMessage);
57
+ break;
58
+ case 'joint_update':
59
+ this.handleJointUpdate(message as JointUpdateMessage);
60
+ break;
61
+ case 'emergency_stop':
62
+ console.warn(`🚨 Emergency stop: ${message.reason || 'Unknown reason'}`);
63
+ this.handleError(`Emergency stop: ${message.reason || 'Unknown reason'}`);
64
+ break;
65
+ case 'error':
66
+ console.error(`Server error: ${message.message}`);
67
+ this.handleError(message.message);
68
+ break;
69
+ default:
70
+ console.warn(`Unknown message type for consumer: ${message.type}`);
71
+ }
72
+ }
73
+
74
+ private handleStateSync(message: StateSyncMessage): void {
75
+ if (this.onStateSyncCallback) {
76
+ this.onStateSyncCallback(message.data);
77
+ }
78
+ this.emit('stateSync', message.data);
79
+ }
80
+
81
+ private handleJointUpdate(message: JointUpdateMessage): void {
82
+ if (this.onJointUpdateCallback) {
83
+ this.onJointUpdateCallback(message.data);
84
+ }
85
+ this.emit('jointUpdate', message.data);
86
+ }
87
+
88
+ // ============= UTILITY METHODS =============
89
+
90
+ /**
91
+ * Create a consumer and automatically connect to a room
92
+ */
93
+ static async createAndConnect(
94
+ workspaceId: string,
95
+ roomId: string,
96
+ baseUrl = 'http://localhost:8000',
97
+ participantId?: string
98
+ ): Promise<RoboticsConsumer> {
99
+ const consumer = new RoboticsConsumer(baseUrl);
100
+ const connected = await consumer.connect(workspaceId, roomId, participantId);
101
+
102
+ if (!connected) {
103
+ throw new Error('Failed to connect as consumer');
104
+ }
105
+
106
+ return consumer;
107
+ }
108
+ }
client/js/src/robotics/core.ts ADDED
@@ -0,0 +1,295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Core robotics client for LeRobot Arena
3
+ * Base class providing REST API and WebSocket functionality
4
+ */
5
+
6
+ import { EventEmitter } from 'eventemitter3';
7
+ import type {
8
+ ParticipantRole,
9
+ RoomInfo,
10
+ RoomState,
11
+ ConnectionInfo,
12
+ WebSocketMessage,
13
+ JoinMessage,
14
+ ListRoomsResponse,
15
+ CreateRoomResponse,
16
+ GetRoomResponse,
17
+ GetRoomStateResponse,
18
+ DeleteRoomResponse,
19
+ ClientOptions,
20
+ ErrorCallback,
21
+ ConnectedCallback,
22
+ DisconnectedCallback,
23
+ } from './types.js';
24
+
25
+ export class RoboticsClientCore extends EventEmitter {
26
+ protected baseUrl: string;
27
+ protected apiBase: string;
28
+ protected websocket: WebSocket | null = null;
29
+ protected workspaceId: string | null = null;
30
+ protected roomId: string | null = null;
31
+ protected role: ParticipantRole | null = null;
32
+ protected participantId: string | null = null;
33
+ protected connected = false;
34
+ protected options: ClientOptions;
35
+
36
+ // Event callbacks
37
+ protected onErrorCallback: ErrorCallback | null = null;
38
+ protected onConnectedCallback: ConnectedCallback | null = null;
39
+ protected onDisconnectedCallback: DisconnectedCallback | null = null;
40
+
41
+ constructor(baseUrl = 'http://localhost:8000', options: ClientOptions = {}) {
42
+ super();
43
+ this.baseUrl = baseUrl.replace(/\/$/, '');
44
+ this.apiBase = `${this.baseUrl}/robotics`;
45
+ this.options = {
46
+ timeout: 5000,
47
+ reconnect_attempts: 3,
48
+ heartbeat_interval: 30000,
49
+ ...options,
50
+ };
51
+ }
52
+
53
+ // ============= REST API METHODS =============
54
+
55
+ async listRooms(workspaceId: string): Promise<RoomInfo[]> {
56
+ const response = await this.fetchApi<ListRoomsResponse>(`/workspaces/${workspaceId}/rooms`);
57
+ return response.rooms;
58
+ }
59
+
60
+ async createRoom(workspaceId?: string, roomId?: string): Promise<{ workspaceId: string; roomId: string }> {
61
+ // Generate workspace ID if not provided
62
+ const finalWorkspaceId = workspaceId || this.generateWorkspaceId();
63
+
64
+ const payload = roomId ? { room_id: roomId, workspace_id: finalWorkspaceId } : { workspace_id: finalWorkspaceId };
65
+ const response = await this.fetchApi<CreateRoomResponse>(`/workspaces/${finalWorkspaceId}/rooms`, {
66
+ method: 'POST',
67
+ headers: { 'Content-Type': 'application/json' },
68
+ body: JSON.stringify(payload),
69
+ });
70
+ return { workspaceId: response.workspace_id, roomId: response.room_id };
71
+ }
72
+
73
+ async deleteRoom(workspaceId: string, roomId: string): Promise<boolean> {
74
+ try {
75
+ const response = await this.fetchApi<DeleteRoomResponse>(`/workspaces/${workspaceId}/rooms/${roomId}`, {
76
+ method: 'DELETE',
77
+ });
78
+ return response.success;
79
+ } catch (error) {
80
+ if (error instanceof Error && error.message.includes('404')) {
81
+ return false;
82
+ }
83
+ throw error;
84
+ }
85
+ }
86
+
87
+ async getRoomState(workspaceId: string, roomId: string): Promise<RoomState> {
88
+ const response = await this.fetchApi<GetRoomStateResponse>(`/workspaces/${workspaceId}/rooms/${roomId}/state`);
89
+ return response.state;
90
+ }
91
+
92
+ async getRoomInfo(workspaceId: string, roomId: string): Promise<RoomInfo> {
93
+ const response = await this.fetchApi<GetRoomResponse>(`/workspaces/${workspaceId}/rooms/${roomId}`);
94
+ return response.room;
95
+ }
96
+
97
+ // ============= WEBSOCKET CONNECTION =============
98
+
99
+ async connectToRoom(
100
+ workspaceId: string,
101
+ roomId: string,
102
+ role: ParticipantRole,
103
+ participantId?: string
104
+ ): Promise<boolean> {
105
+ if (this.connected) {
106
+ await this.disconnect();
107
+ }
108
+
109
+ this.workspaceId = workspaceId;
110
+ this.roomId = roomId;
111
+ this.role = role;
112
+ this.participantId = participantId || `${role}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
113
+
114
+ // Convert HTTP URL to WebSocket URL
115
+ const wsUrl = this.baseUrl
116
+ .replace(/^http/, 'ws')
117
+ .replace(/^https/, 'wss');
118
+ const wsEndpoint = `${wsUrl}/robotics/workspaces/${workspaceId}/rooms/${roomId}/ws`;
119
+
120
+ try {
121
+ this.websocket = new WebSocket(wsEndpoint);
122
+
123
+ // Set up WebSocket event handlers
124
+ return new Promise((resolve, reject) => {
125
+ const timeout = setTimeout(() => {
126
+ reject(new Error('Connection timeout'));
127
+ }, this.options.timeout || 5000);
128
+
129
+ this.websocket!.onopen = () => {
130
+ clearTimeout(timeout);
131
+ this.sendJoinMessage();
132
+ };
133
+
134
+ this.websocket!.onmessage = (event) => {
135
+ try {
136
+ const message: WebSocketMessage = JSON.parse(event.data);
137
+ this.handleMessage(message);
138
+
139
+ // Handle initial connection responses
140
+ if (message.type === 'joined') {
141
+ this.connected = true;
142
+ this.onConnectedCallback?.();
143
+ this.emit('connected');
144
+ resolve(true);
145
+ } else if (message.type === 'error') {
146
+ this.handleError(message.message);
147
+ resolve(false);
148
+ }
149
+ } catch (error) {
150
+ console.error('Failed to parse WebSocket message:', error);
151
+ }
152
+ };
153
+
154
+ this.websocket!.onerror = (error) => {
155
+ clearTimeout(timeout);
156
+ console.error('WebSocket error:', error);
157
+ this.handleError('WebSocket connection error');
158
+ reject(error);
159
+ };
160
+
161
+ this.websocket!.onclose = () => {
162
+ clearTimeout(timeout);
163
+ this.connected = false;
164
+ this.onDisconnectedCallback?.();
165
+ this.emit('disconnected');
166
+ };
167
+ });
168
+ } catch (error) {
169
+ console.error('Failed to connect to room:', error);
170
+ return false;
171
+ }
172
+ }
173
+
174
+ async disconnect(): Promise<void> {
175
+ if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
176
+ this.websocket.close();
177
+ }
178
+
179
+ this.websocket = null;
180
+ this.connected = false;
181
+ this.workspaceId = null;
182
+ this.roomId = null;
183
+ this.role = null;
184
+ this.participantId = null;
185
+
186
+ this.onDisconnectedCallback?.();
187
+ this.emit('disconnected');
188
+ }
189
+
190
+ // ============= MESSAGE HANDLING =============
191
+
192
+ protected sendJoinMessage(): void {
193
+ if (!this.websocket || !this.participantId || !this.role) return;
194
+
195
+ const joinMessage: JoinMessage = {
196
+ participant_id: this.participantId,
197
+ role: this.role,
198
+ };
199
+
200
+ this.websocket.send(JSON.stringify(joinMessage));
201
+ }
202
+
203
+ protected handleMessage(message: WebSocketMessage): void {
204
+ switch (message.type) {
205
+ case 'joined':
206
+ console.log(`Successfully joined room ${message.room_id} as ${message.role}`);
207
+ break;
208
+ case 'heartbeat_ack':
209
+ console.debug('Heartbeat acknowledged');
210
+ break;
211
+ case 'error':
212
+ this.handleError(message.message);
213
+ break;
214
+ default:
215
+ // Let subclasses handle specific message types
216
+ this.handleRoleSpecificMessage(message);
217
+ }
218
+ }
219
+
220
+ protected handleRoleSpecificMessage(message: WebSocketMessage): void {
221
+ // To be overridden by subclasses
222
+ this.emit('message', message);
223
+ }
224
+
225
+ protected handleError(errorMessage: string): void {
226
+ console.error('Client error:', errorMessage);
227
+ this.onErrorCallback?.(errorMessage);
228
+ this.emit('error', errorMessage);
229
+ }
230
+
231
+ // ============= UTILITY METHODS =============
232
+
233
+ async sendHeartbeat(): Promise<void> {
234
+ if (!this.connected || !this.websocket) return;
235
+
236
+ const message = { type: 'heartbeat' as const };
237
+ this.websocket.send(JSON.stringify(message));
238
+ }
239
+
240
+ isConnected(): boolean {
241
+ return this.connected;
242
+ }
243
+
244
+ getConnectionInfo(): ConnectionInfo {
245
+ return {
246
+ connected: this.connected,
247
+ workspace_id: this.workspaceId,
248
+ room_id: this.roomId,
249
+ role: this.role,
250
+ participant_id: this.participantId,
251
+ base_url: this.baseUrl,
252
+ };
253
+ }
254
+
255
+ // ============= EVENT CALLBACK SETTERS =============
256
+
257
+ onError(callback: ErrorCallback): void {
258
+ this.onErrorCallback = callback;
259
+ }
260
+
261
+ onConnected(callback: ConnectedCallback): void {
262
+ this.onConnectedCallback = callback;
263
+ }
264
+
265
+ onDisconnected(callback: DisconnectedCallback): void {
266
+ this.onDisconnectedCallback = callback;
267
+ }
268
+
269
+ // ============= PRIVATE HELPERS =============
270
+
271
+ private async fetchApi<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
272
+ const url = `${this.apiBase}${endpoint}`;
273
+ const response = await fetch(url, {
274
+ ...options,
275
+ signal: AbortSignal.timeout(this.options.timeout || 5000),
276
+ });
277
+
278
+ if (!response.ok) {
279
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
280
+ }
281
+
282
+ return response.json() as Promise<T>;
283
+ }
284
+
285
+ // ============= WORKSPACE HELPERS =============
286
+
287
+ protected generateWorkspaceId(): string {
288
+ // Generate a UUID-like workspace ID
289
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
290
+ const r = Math.random() * 16 | 0;
291
+ const v = c === 'x' ? r : (r & 0x3 | 0x8);
292
+ return v.toString(16);
293
+ });
294
+ }
295
+ }
client/js/src/robotics/factory.ts ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Factory functions for creating LeRobot Arena robotics clients
3
+ */
4
+
5
+ import { RoboticsProducer } from './producer.js';
6
+ import { RoboticsConsumer } from './consumer.js';
7
+ import type { ParticipantRole, ClientOptions } from './types.js';
8
+
9
+ /**
10
+ * Factory function to create the appropriate client based on role
11
+ */
12
+ export function createClient(
13
+ role: ParticipantRole,
14
+ baseUrl = 'http://localhost:8000',
15
+ options: ClientOptions = {}
16
+ ): RoboticsProducer | RoboticsConsumer {
17
+ if (role === 'producer') {
18
+ return new RoboticsProducer(baseUrl, options);
19
+ }
20
+ if (role === 'consumer') {
21
+ return new RoboticsConsumer(baseUrl, options);
22
+ }
23
+ throw new Error(`Invalid role: ${role}. Must be 'producer' or 'consumer'`);
24
+ }
25
+
26
+ /**
27
+ * Create and connect a producer client
28
+ */
29
+ export async function createProducerClient(
30
+ baseUrl = 'http://localhost:8000',
31
+ workspaceId?: string,
32
+ roomId?: string,
33
+ participantId?: string,
34
+ options: ClientOptions = {}
35
+ ): Promise<RoboticsProducer> {
36
+ const producer = new RoboticsProducer(baseUrl, options);
37
+
38
+ const roomData = await producer.createRoom(workspaceId, roomId);
39
+ const connected = await producer.connect(roomData.workspaceId, roomData.roomId, participantId);
40
+
41
+ if (!connected) {
42
+ throw new Error('Failed to connect as producer');
43
+ }
44
+
45
+ return producer;
46
+ }
47
+
48
+ /**
49
+ * Create and connect a consumer client
50
+ */
51
+ export async function createConsumerClient(
52
+ workspaceId: string,
53
+ roomId: string,
54
+ baseUrl = 'http://localhost:8000',
55
+ participantId?: string,
56
+ options: ClientOptions = {}
57
+ ): Promise<RoboticsConsumer> {
58
+ const consumer = new RoboticsConsumer(baseUrl, options);
59
+ const connected = await consumer.connect(workspaceId, roomId, participantId);
60
+
61
+ if (!connected) {
62
+ throw new Error('Failed to connect as consumer');
63
+ }
64
+
65
+ return consumer;
66
+ }
client/js/src/robotics/index.ts ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * LeRobot Arena Robotics Client - Main Module
3
+ *
4
+ * TypeScript/JavaScript client for robotics control and monitoring
5
+ */
6
+
7
+ // Export core classes
8
+ export { RoboticsClientCore } from './core.js';
9
+ export { RoboticsProducer } from './producer.js';
10
+ export { RoboticsConsumer } from './consumer.js';
11
+
12
+ // Export all types
13
+ export * from './types.js';
14
+
15
+ // Export factory functions for convenience
16
+ export { createProducerClient, createConsumerClient, createClient } from './factory.js';
client/js/src/robotics/producer.ts ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Producer client for controlling robots in LeRobot Arena
3
+ */
4
+
5
+ import { RoboticsClientCore } from './core.js';
6
+ import type {
7
+ JointData,
8
+ WebSocketMessage,
9
+ JointUpdateMessage,
10
+ ClientOptions,
11
+ } from './types.js';
12
+
13
+ export class RoboticsProducer extends RoboticsClientCore {
14
+ constructor(baseUrl = 'http://localhost:8000', options: ClientOptions = {}) {
15
+ super(baseUrl, options);
16
+ }
17
+
18
+ // ============= PRODUCER CONNECTION =============
19
+
20
+ async connect(workspaceId: string, roomId: string, participantId?: string): Promise<boolean> {
21
+ return this.connectToRoom(workspaceId, roomId, 'producer', participantId);
22
+ }
23
+
24
+ // ============= PRODUCER METHODS =============
25
+
26
+ async sendJointUpdate(joints: JointData[]): Promise<void> {
27
+ if (!this.connected || !this.websocket) {
28
+ throw new Error('Must be connected to send joint updates');
29
+ }
30
+
31
+ const message: JointUpdateMessage = {
32
+ type: 'joint_update',
33
+ data: joints,
34
+ timestamp: new Date().toISOString(),
35
+ };
36
+
37
+ this.websocket.send(JSON.stringify(message));
38
+ }
39
+
40
+ async sendStateSync(state: Record<string, number>): Promise<void> {
41
+ if (!this.connected || !this.websocket) {
42
+ throw new Error('Must be connected to send state sync');
43
+ }
44
+
45
+ // Convert state object to joint updates format
46
+ const joints: JointData[] = Object.entries(state).map(([name, value]) => ({
47
+ name,
48
+ value,
49
+ }));
50
+
51
+ await this.sendJointUpdate(joints);
52
+ }
53
+
54
+ async sendEmergencyStop(reason = 'Emergency stop'): Promise<void> {
55
+ if (!this.connected || !this.websocket) {
56
+ throw new Error('Must be connected to send emergency stop');
57
+ }
58
+
59
+ const message = {
60
+ type: 'emergency_stop' as const,
61
+ reason,
62
+ timestamp: new Date().toISOString(),
63
+ };
64
+
65
+ this.websocket.send(JSON.stringify(message));
66
+ }
67
+
68
+ // ============= MESSAGE HANDLING =============
69
+
70
+ protected override handleRoleSpecificMessage(message: WebSocketMessage): void {
71
+ switch (message.type) {
72
+ case 'emergency_stop':
73
+ console.warn(`🚨 Emergency stop: ${message.reason || 'Unknown reason'}`);
74
+ this.handleError(`Emergency stop: ${message.reason || 'Unknown reason'}`);
75
+ break;
76
+ case 'error':
77
+ console.error(`Server error: ${message.message}`);
78
+ this.handleError(message.message);
79
+ break;
80
+ default:
81
+ console.warn(`Unknown message type for producer: ${message.type}`);
82
+ }
83
+ }
84
+
85
+ // ============= UTILITY METHODS =============
86
+
87
+ /**
88
+ * Create a room and automatically connect as producer
89
+ */
90
+ static async createAndConnect(
91
+ baseUrl = 'http://localhost:8000',
92
+ workspaceId?: string,
93
+ roomId?: string,
94
+ participantId?: string
95
+ ): Promise<RoboticsProducer> {
96
+ const producer = new RoboticsProducer(baseUrl);
97
+
98
+ const roomData = await producer.createRoom(workspaceId, roomId);
99
+ const connected = await producer.connect(roomData.workspaceId, roomData.roomId, participantId);
100
+
101
+ if (!connected) {
102
+ throw new Error('Failed to connect as producer');
103
+ }
104
+
105
+ return producer;
106
+ }
107
+
108
+ /**
109
+ * Get the current room ID (useful when auto-created)
110
+ */
111
+ get currentRoomId(): string | null {
112
+ return this.roomId;
113
+ }
114
+ }
client/js/src/robotics/types.ts ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Type definitions for LeRobot Arena Robotics Client
3
+ */
4
+
5
+ // ============= CORE TYPES =============
6
+
7
+ export type ParticipantRole = 'producer' | 'consumer';
8
+
9
+ export type MessageType =
10
+ | 'joint_update'
11
+ | 'state_sync'
12
+ | 'heartbeat'
13
+ | 'heartbeat_ack'
14
+ | 'emergency_stop'
15
+ | 'joined'
16
+ | 'error';
17
+
18
+ // ============= DATA STRUCTURES =============
19
+
20
+ export interface JointData {
21
+ name: string;
22
+ value: number;
23
+ speed?: number;
24
+ }
25
+
26
+ export interface RoomInfo {
27
+ id: string;
28
+ workspace_id: string;
29
+ participants: {
30
+ producer: string | null;
31
+ consumers: string[];
32
+ total: number;
33
+ };
34
+ joints_count: number;
35
+ has_producer?: boolean;
36
+ active_consumers?: number;
37
+ }
38
+
39
+ export interface RoomState {
40
+ room_id: string;
41
+ workspace_id: string;
42
+ joints: Record<string, number>;
43
+ participants: {
44
+ producer: string | null;
45
+ consumers: string[];
46
+ total: number;
47
+ };
48
+ timestamp: string;
49
+ }
50
+
51
+ export interface ConnectionInfo {
52
+ connected: boolean;
53
+ workspace_id: string | null;
54
+ room_id: string | null;
55
+ role: ParticipantRole | null;
56
+ participant_id: string | null;
57
+ base_url: string;
58
+ }
59
+
60
+ // ============= MESSAGE TYPES =============
61
+
62
+ export interface BaseMessage {
63
+ type: MessageType;
64
+ timestamp?: string;
65
+ }
66
+
67
+ export interface JointUpdateMessage extends BaseMessage {
68
+ type: 'joint_update';
69
+ data: JointData[];
70
+ source?: string;
71
+ }
72
+
73
+ export interface StateSyncMessage extends BaseMessage {
74
+ type: 'state_sync';
75
+ data: Record<string, number>;
76
+ }
77
+
78
+ export interface HeartbeatMessage extends BaseMessage {
79
+ type: 'heartbeat';
80
+ }
81
+
82
+ export interface HeartbeatAckMessage extends BaseMessage {
83
+ type: 'heartbeat_ack';
84
+ }
85
+
86
+ export interface EmergencyStopMessage extends BaseMessage {
87
+ type: 'emergency_stop';
88
+ reason: string;
89
+ source?: string;
90
+ }
91
+
92
+ export interface JoinedMessage extends BaseMessage {
93
+ type: 'joined';
94
+ room_id: string;
95
+ role: ParticipantRole;
96
+ }
97
+
98
+ export interface ErrorMessage extends BaseMessage {
99
+ type: 'error';
100
+ message: string;
101
+ }
102
+
103
+ export type WebSocketMessage =
104
+ | JointUpdateMessage
105
+ | StateSyncMessage
106
+ | HeartbeatMessage
107
+ | HeartbeatAckMessage
108
+ | EmergencyStopMessage
109
+ | JoinedMessage
110
+ | ErrorMessage;
111
+
112
+ // ============= API RESPONSE TYPES =============
113
+
114
+ export interface ApiResponse<T = unknown> {
115
+ success: boolean;
116
+ data?: T;
117
+ error?: string;
118
+ message?: string;
119
+ }
120
+
121
+ export interface ListRoomsResponse {
122
+ success: boolean;
123
+ workspace_id: string;
124
+ rooms: RoomInfo[];
125
+ total: number;
126
+ }
127
+
128
+ export interface CreateRoomResponse {
129
+ success: boolean;
130
+ workspace_id: string;
131
+ room_id: string;
132
+ message: string;
133
+ }
134
+
135
+ export interface GetRoomResponse {
136
+ success: boolean;
137
+ workspace_id: string;
138
+ room: RoomInfo;
139
+ }
140
+
141
+ export interface GetRoomStateResponse {
142
+ success: boolean;
143
+ workspace_id: string;
144
+ state: RoomState;
145
+ }
146
+
147
+ export interface DeleteRoomResponse {
148
+ success: boolean;
149
+ workspace_id: string;
150
+ message: string;
151
+ }
152
+
153
+ // ============= REQUEST TYPES =============
154
+
155
+ export interface CreateRoomRequest {
156
+ room_id?: string;
157
+ workspace_id?: string; // Optional - will be generated if not provided
158
+ }
159
+
160
+ export interface JoinMessage {
161
+ participant_id: string;
162
+ role: ParticipantRole;
163
+ }
164
+
165
+ // ============= EVENT CALLBACK TYPES =============
166
+
167
+ export type JointUpdateCallback = (joints: JointData[]) => void;
168
+ export type StateSyncCallback = (state: Record<string, number>) => void;
169
+ export type ErrorCallback = (error: string) => void;
170
+ export type ConnectedCallback = () => void;
171
+ export type DisconnectedCallback = () => void;
172
+
173
+ // ============= CLIENT OPTIONS =============
174
+
175
+ export interface ClientOptions {
176
+ base_url?: string;
177
+ timeout?: number;
178
+ reconnect_attempts?: number;
179
+ heartbeat_interval?: number;
180
+ }
client/js/src/video/consumer.ts ADDED
@@ -0,0 +1,430 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Consumer client for receiving video streams in LeRobot Arena
3
+ */
4
+
5
+ import { VideoClientCore } from './core.js';
6
+ import type {
7
+ WebSocketMessage,
8
+ FrameUpdateMessage,
9
+ VideoConfigUpdateMessage,
10
+ StreamStartedMessage,
11
+ StreamStoppedMessage,
12
+ RecoveryTriggeredMessage,
13
+ StatusUpdateMessage,
14
+ StreamStatsMessage,
15
+ ClientOptions,
16
+ WebRTCStats,
17
+ FrameUpdateCallback,
18
+ VideoConfigUpdateCallback,
19
+ StreamStartedCallback,
20
+ StreamStoppedCallback,
21
+ RecoveryTriggeredCallback,
22
+ StatusUpdateCallback,
23
+ StreamStatsCallback,
24
+ WebRTCOfferMessage,
25
+ WebRTCIceMessage,
26
+ } from './types.js';
27
+
28
+ export class VideoConsumer extends VideoClientCore {
29
+ // Event callbacks
30
+ private onFrameUpdateCallback: FrameUpdateCallback | null = null;
31
+ private onVideoConfigUpdateCallback: VideoConfigUpdateCallback | null = null;
32
+ private onStreamStartedCallback: StreamStartedCallback | null = null;
33
+ private onStreamStoppedCallback: StreamStoppedCallback | null = null;
34
+ private onRecoveryTriggeredCallback: RecoveryTriggeredCallback | null = null;
35
+ private onStatusUpdateCallback: StatusUpdateCallback | null = null;
36
+ private onStreamStatsCallback: StreamStatsCallback | null = null;
37
+
38
+ // ICE candidate queuing for proper timing
39
+ private iceCandidateQueue: { candidate: RTCIceCandidate; fromProducer: string }[] = [];
40
+ private hasRemoteDescription = false;
41
+
42
+ constructor(baseUrl = 'http://localhost:8000', options: ClientOptions = {}) {
43
+ super(baseUrl, options);
44
+ }
45
+
46
+ // ============= CONSUMER CONNECTION =============
47
+
48
+ async connect(workspaceId: string, roomId: string, participantId?: string): Promise<boolean> {
49
+ const connected = await this.connectToRoom(workspaceId, roomId, 'consumer', participantId);
50
+
51
+ if (connected) {
52
+ // Create peer connection immediately so we're ready for WebRTC offers
53
+ console.info('🔧 Creating peer connection for consumer...');
54
+ await this.startReceiving();
55
+ }
56
+
57
+ return connected;
58
+ }
59
+
60
+ // ============= CONSUMER METHODS =============
61
+
62
+ async startReceiving(): Promise<void> {
63
+ if (!this.connected) {
64
+ throw new Error('Must be connected to start receiving');
65
+ }
66
+
67
+ // Reset WebRTC state
68
+ this.hasRemoteDescription = false;
69
+ this.iceCandidateQueue = [];
70
+
71
+ // Create peer connection for receiving
72
+ this.createPeerConnection();
73
+
74
+ // Set up to receive remote stream
75
+ if (this.peerConnection) {
76
+ this.peerConnection.ontrack = (event: RTCTrackEvent) => {
77
+ console.info('📺 Received remote track:', event.track.kind);
78
+ this.remoteStream = event.streams[0] || null;
79
+ this.emit('remoteStream', this.remoteStream);
80
+ this.emit('streamReceived', this.remoteStream);
81
+ };
82
+ }
83
+ }
84
+
85
+ async stopReceiving(): Promise<void> {
86
+ if (this.peerConnection) {
87
+ this.peerConnection.close();
88
+ this.peerConnection = null;
89
+ }
90
+ this.remoteStream = null;
91
+ this.emit('streamStopped');
92
+ }
93
+
94
+ // ============= WEBRTC NEGOTIATION =============
95
+
96
+ async handleWebRTCOffer(message: WebRTCOfferMessage): Promise<void> {
97
+ try {
98
+ console.info(`📥 Received WebRTC offer from producer ${message.from_producer}`);
99
+
100
+ if (!this.peerConnection) {
101
+ console.warn('No peer connection available to handle offer');
102
+ return;
103
+ }
104
+
105
+ // Reset state for new offer
106
+ this.hasRemoteDescription = false;
107
+ this.iceCandidateQueue = [];
108
+
109
+ // Set remote description (the offer)
110
+ await this.setRemoteDescription(message.offer);
111
+ this.hasRemoteDescription = true;
112
+
113
+ // Process any queued ICE candidates now that we have remote description
114
+ await this.processQueuedIceCandidates();
115
+
116
+ // Create answer
117
+ const answer = await this.createAnswer(message.offer);
118
+
119
+ console.info(`📤 Sending WebRTC answer to producer ${message.from_producer}`);
120
+
121
+ // Send answer back through server to producer
122
+ if (this.workspaceId && this.roomId && this.participantId) {
123
+ await this.sendWebRTCSignal(this.workspaceId, this.roomId, this.participantId, {
124
+ type: 'answer',
125
+ sdp: answer.sdp,
126
+ target_producer: message.from_producer,
127
+ } as Record<string, unknown>);
128
+ }
129
+
130
+ console.info('✅ WebRTC negotiation completed from consumer side');
131
+ } catch (error) {
132
+ console.error('Failed to handle WebRTC offer:', error);
133
+ this.handleError(`Failed to handle WebRTC offer: ${error}`);
134
+ }
135
+ }
136
+
137
+ private async handleWebRTCIce(message: WebRTCIceMessage): Promise<void> {
138
+ if (!this.peerConnection) {
139
+ console.warn('No peer connection available to handle ICE');
140
+ return;
141
+ }
142
+
143
+ try {
144
+ console.info(`📥 Received WebRTC ICE from producer ${message.from_producer}`);
145
+
146
+ const candidate = new RTCIceCandidate(message.candidate);
147
+
148
+ if (!this.hasRemoteDescription) {
149
+ // Queue ICE candidate until we have remote description
150
+ console.info(`🔄 Queuing ICE candidate from ${message.from_producer} (no remote description yet)`);
151
+ this.iceCandidateQueue.push({
152
+ candidate,
153
+ fromProducer: message.from_producer || 'unknown'
154
+ });
155
+ return;
156
+ }
157
+
158
+ // Add ICE candidate to peer connection
159
+ await this.peerConnection.addIceCandidate(candidate);
160
+
161
+ console.info(`✅ WebRTC ICE handled from producer ${message.from_producer}`);
162
+ } catch (error) {
163
+ console.error(`Failed to handle WebRTC ICE from ${message.from_producer}:`, error);
164
+ this.handleError(`Failed to handle WebRTC ICE: ${error}`);
165
+ }
166
+ }
167
+
168
+ private async processQueuedIceCandidates(): Promise<void> {
169
+ if (this.iceCandidateQueue.length === 0) {
170
+ return;
171
+ }
172
+
173
+ console.info(`🔄 Processing ${this.iceCandidateQueue.length} queued ICE candidates`);
174
+
175
+ for (const { candidate, fromProducer } of this.iceCandidateQueue) {
176
+ try {
177
+ if (this.peerConnection) {
178
+ await this.peerConnection.addIceCandidate(candidate);
179
+ console.info(`✅ Processed queued ICE candidate from ${fromProducer}`);
180
+ }
181
+ } catch (error) {
182
+ console.error(`Failed to process queued ICE candidate from ${fromProducer}:`, error);
183
+ }
184
+ }
185
+
186
+ // Clear the queue
187
+ this.iceCandidateQueue = [];
188
+ }
189
+
190
+ // Override to add producer targeting for ICE candidates
191
+ override createPeerConnection(): RTCPeerConnection {
192
+ const config: RTCConfiguration = {
193
+ iceServers: this.webrtcConfig.iceServers || [
194
+ { urls: 'stun:stun.l.google.com:19302' }
195
+ ]
196
+ };
197
+
198
+ this.peerConnection = new RTCPeerConnection(config);
199
+
200
+ // Connection state changes
201
+ this.peerConnection.onconnectionstatechange = () => {
202
+ const state = this.peerConnection?.connectionState;
203
+ console.info(`🔌 WebRTC connection state: ${state}`);
204
+ };
205
+
206
+ // ICE connection state
207
+ this.peerConnection.oniceconnectionstatechange = () => {
208
+ const state = this.peerConnection?.iceConnectionState;
209
+ console.info(`🧊 ICE connection state: ${state}`);
210
+ };
211
+
212
+ // ICE candidate handling - send to producer
213
+ this.peerConnection.onicecandidate = (event: RTCPeerConnectionIceEvent) => {
214
+ if (event.candidate && this.workspaceId && this.roomId && this.participantId) {
215
+ // Send ICE candidate to producer
216
+ this.sendIceCandidateToProducer(event.candidate);
217
+ }
218
+ };
219
+
220
+ // Handle remote stream
221
+ this.peerConnection.ontrack = (event: RTCTrackEvent) => {
222
+ console.info('📺 Received remote track:', event.track.kind);
223
+ this.remoteStream = event.streams[0] || null;
224
+ this.emit('remoteStream', this.remoteStream);
225
+ this.emit('streamReceived', this.remoteStream);
226
+ };
227
+
228
+ return this.peerConnection;
229
+ }
230
+
231
+ private async sendIceCandidateToProducer(candidate: RTCIceCandidate): Promise<void> {
232
+ if (!this.workspaceId || !this.roomId || !this.participantId) return;
233
+
234
+ try {
235
+ // Get room info to find the producer
236
+ const roomInfo = await this.getRoomInfo(this.workspaceId, this.roomId);
237
+
238
+ if (roomInfo.participants.producer) {
239
+ await this.sendWebRTCSignal(this.workspaceId, this.roomId, this.participantId, {
240
+ type: 'ice',
241
+ candidate: candidate.toJSON(),
242
+ target_producer: roomInfo.participants.producer,
243
+ } as Record<string, unknown>);
244
+ }
245
+ } catch (error) {
246
+ console.error('Failed to send ICE candidate to producer:', error);
247
+ }
248
+ }
249
+
250
+ private async handleStreamStarted(message: StreamStartedMessage): Promise<void> {
251
+ if (this.onStreamStartedCallback) {
252
+ this.onStreamStartedCallback(message.config, message.participant_id);
253
+ }
254
+ this.emit('streamStarted', message.config, message.participant_id);
255
+
256
+ console.info(`🚀 Stream started by producer ${message.participant_id}, ready to receive video`);
257
+ }
258
+
259
+ // ============= EVENT CALLBACKS =============
260
+
261
+ onFrameUpdate(callback: FrameUpdateCallback): void {
262
+ this.onFrameUpdateCallback = callback;
263
+ }
264
+
265
+ onVideoConfigUpdate(callback: VideoConfigUpdateCallback): void {
266
+ this.onVideoConfigUpdateCallback = callback;
267
+ }
268
+
269
+ onStreamStarted(callback: StreamStartedCallback): void {
270
+ this.onStreamStartedCallback = callback;
271
+ }
272
+
273
+ onStreamStopped(callback: StreamStoppedCallback): void {
274
+ this.onStreamStoppedCallback = callback;
275
+ }
276
+
277
+ onRecoveryTriggered(callback: RecoveryTriggeredCallback): void {
278
+ this.onRecoveryTriggeredCallback = callback;
279
+ }
280
+
281
+ onStatusUpdate(callback: StatusUpdateCallback): void {
282
+ this.onStatusUpdateCallback = callback;
283
+ }
284
+
285
+ onStreamStats(callback: StreamStatsCallback): void {
286
+ this.onStreamStatsCallback = callback;
287
+ }
288
+
289
+ // ============= MESSAGE HANDLING =============
290
+
291
+ protected override handleRoleSpecificMessage(message: WebSocketMessage): void {
292
+ switch (message.type) {
293
+ case 'frame_update':
294
+ this.handleFrameUpdate(message as FrameUpdateMessage);
295
+ break;
296
+ case 'video_config_update':
297
+ this.handleVideoConfigUpdate(message as VideoConfigUpdateMessage);
298
+ break;
299
+ case 'stream_started':
300
+ this.handleStreamStarted(message as StreamStartedMessage);
301
+ break;
302
+ case 'stream_stopped':
303
+ this.handleStreamStopped(message as StreamStoppedMessage);
304
+ break;
305
+ case 'recovery_triggered':
306
+ this.handleRecoveryTriggered(message as RecoveryTriggeredMessage);
307
+ break;
308
+ case 'status_update':
309
+ this.handleStatusUpdate(message as StatusUpdateMessage);
310
+ break;
311
+ case 'stream_stats':
312
+ this.handleStreamStats(message as StreamStatsMessage);
313
+ break;
314
+ case 'participant_joined':
315
+ console.info(`📥 Participant joined: ${message.participant_id} as ${message.role}`);
316
+ break;
317
+ case 'participant_left':
318
+ console.info(`📤 Participant left: ${message.participant_id} (${message.role})`);
319
+ break;
320
+ case 'webrtc_offer':
321
+ this.handleWebRTCOffer(message as WebRTCOfferMessage);
322
+ break;
323
+ case 'webrtc_answer':
324
+ console.info('📨 Received WebRTC answer (consumer should not receive this)');
325
+ break;
326
+ case 'webrtc_ice':
327
+ this.handleWebRTCIce(message as WebRTCIceMessage);
328
+ break;
329
+ case 'emergency_stop':
330
+ console.warn(`🚨 Emergency stop: ${message.reason || 'Unknown reason'}`);
331
+ this.handleError(`Emergency stop: ${message.reason || 'Unknown reason'}`);
332
+ break;
333
+ case 'error':
334
+ console.error(`Server error: ${message.message}`);
335
+ this.handleError(message.message);
336
+ break;
337
+ default:
338
+ console.warn(`Unknown message type for consumer: ${message.type}`);
339
+ }
340
+ }
341
+
342
+ private handleFrameUpdate(message: FrameUpdateMessage): void {
343
+ if (this.onFrameUpdateCallback) {
344
+ const frameData = {
345
+ data: message.data,
346
+ metadata: message.metadata
347
+ };
348
+ this.onFrameUpdateCallback(frameData);
349
+ }
350
+ this.emit('frameUpdate', message.data);
351
+ }
352
+
353
+ private handleVideoConfigUpdate(message: VideoConfigUpdateMessage): void {
354
+ if (this.onVideoConfigUpdateCallback) {
355
+ this.onVideoConfigUpdateCallback(message.config);
356
+ }
357
+ this.emit('videoConfigUpdate', message.config);
358
+ }
359
+
360
+ private handleStreamStopped(message: StreamStoppedMessage): void {
361
+ if (this.onStreamStoppedCallback) {
362
+ this.onStreamStoppedCallback(message.participant_id, message.reason);
363
+ }
364
+ this.emit('streamStopped', message.participant_id, message.reason);
365
+ }
366
+
367
+ private handleRecoveryTriggered(message: RecoveryTriggeredMessage): void {
368
+ if (this.onRecoveryTriggeredCallback) {
369
+ this.onRecoveryTriggeredCallback(message.policy, message.reason);
370
+ }
371
+ this.emit('recoveryTriggered', message.policy, message.reason);
372
+ }
373
+
374
+ private handleStatusUpdate(message: StatusUpdateMessage): void {
375
+ if (this.onStatusUpdateCallback) {
376
+ this.onStatusUpdateCallback(message.status, message.data);
377
+ }
378
+ this.emit('statusUpdate', message.status, message.data);
379
+ }
380
+
381
+ private handleStreamStats(message: StreamStatsMessage): void {
382
+ if (this.onStreamStatsCallback) {
383
+ this.onStreamStatsCallback(message.stats);
384
+ }
385
+ this.emit('streamStats', message.stats);
386
+ }
387
+
388
+ // ============= UTILITY METHODS =============
389
+
390
+ /**
391
+ * Create a consumer and automatically connect to a room
392
+ */
393
+ static async createAndConnect(
394
+ workspaceId: string,
395
+ roomId: string,
396
+ baseUrl = 'http://localhost:8000',
397
+ participantId?: string
398
+ ): Promise<VideoConsumer> {
399
+ const consumer = new VideoConsumer(baseUrl);
400
+ const connected = await consumer.connect(workspaceId, roomId, participantId);
401
+
402
+ if (!connected) {
403
+ throw new Error('Failed to connect as video consumer');
404
+ }
405
+
406
+ return consumer;
407
+ }
408
+
409
+ /**
410
+ * Get the video element for displaying the remote stream
411
+ */
412
+ attachToVideoElement(videoElement: HTMLVideoElement): void {
413
+ if (this.remoteStream) {
414
+ videoElement.srcObject = this.remoteStream;
415
+ }
416
+
417
+ // Listen for future stream updates
418
+ this.on('remoteStream', (stream: MediaStream) => {
419
+ videoElement.srcObject = stream;
420
+ });
421
+ }
422
+
423
+ /**
424
+ * Get current video statistics
425
+ */
426
+ async getVideoStats(): Promise<WebRTCStats | null> {
427
+ const stats = await this.getStats();
428
+ return stats;
429
+ }
430
+ }
client/js/src/video/core.ts ADDED
@@ -0,0 +1,533 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Core video client for LeRobot Arena
3
+ * Base class providing REST API, WebSocket, and WebRTC functionality
4
+ */
5
+
6
+ import { EventEmitter } from 'eventemitter3';
7
+ import type {
8
+ ParticipantRole,
9
+ RoomInfo,
10
+ RoomState,
11
+ ConnectionInfo,
12
+ WebSocketMessage,
13
+ JoinMessage,
14
+ ListRoomsResponse,
15
+ CreateRoomResponse,
16
+ GetRoomResponse,
17
+ GetRoomStateResponse,
18
+ DeleteRoomResponse,
19
+ WebRTCSignalResponse,
20
+ WebRTCSignalRequest,
21
+ ClientOptions,
22
+ WebRTCConfig,
23
+ WebRTCStats,
24
+ VideoConfig,
25
+ RecoveryConfig,
26
+ ErrorCallback,
27
+ ConnectedCallback,
28
+ DisconnectedCallback,
29
+ } from './types.js';
30
+
31
+ export class VideoClientCore extends EventEmitter {
32
+ protected baseUrl: string;
33
+ protected apiBase: string;
34
+ protected websocket: WebSocket | null = null;
35
+ protected peerConnection: RTCPeerConnection | null = null;
36
+ protected localStream: MediaStream | null = null;
37
+ protected remoteStream: MediaStream | null = null;
38
+ protected workspaceId: string | null = null;
39
+ protected roomId: string | null = null;
40
+ protected role: ParticipantRole | null = null;
41
+ protected participantId: string | null = null;
42
+ protected connected = false;
43
+ protected options: ClientOptions;
44
+ protected webrtcConfig: WebRTCConfig;
45
+
46
+ // Event callbacks
47
+ protected onErrorCallback: ErrorCallback | null = null;
48
+ protected onConnectedCallback: ConnectedCallback | null = null;
49
+ protected onDisconnectedCallback: DisconnectedCallback | null = null;
50
+
51
+ constructor(baseUrl = 'http://localhost:8000', options: ClientOptions = {}) {
52
+ super();
53
+ this.baseUrl = baseUrl.replace(/\/$/, '');
54
+ this.apiBase = `${this.baseUrl}/video`;
55
+ this.options = {
56
+ timeout: 5000,
57
+ reconnect_attempts: 3,
58
+ heartbeat_interval: 30000,
59
+ ...options,
60
+ };
61
+ this.webrtcConfig = {
62
+ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
63
+ constraints: {
64
+ video: {
65
+ width: { ideal: 640 },
66
+ height: { ideal: 480 },
67
+ frameRate: { ideal: 30 }
68
+ },
69
+ audio: false
70
+ },
71
+ bitrate: 1000000,
72
+ framerate: 30,
73
+ resolution: { width: 640, height: 480 },
74
+ codecPreferences: ['VP8', 'H264'],
75
+ ...this.options.webrtc_config,
76
+ };
77
+ }
78
+
79
+ // ============= REST API METHODS =============
80
+
81
+ async listRooms(workspaceId: string): Promise<RoomInfo[]> {
82
+ const response = await this.fetchApi<ListRoomsResponse>(`/workspaces/${workspaceId}/rooms`);
83
+ return response.rooms;
84
+ }
85
+
86
+ async createRoom(workspaceId?: string, roomId?: string, config?: VideoConfig, recoveryConfig?: RecoveryConfig): Promise<{ workspaceId: string; roomId: string }> {
87
+ // Generate workspace ID if not provided
88
+ const finalWorkspaceId = workspaceId || this.generateWorkspaceId();
89
+
90
+ const payload = {
91
+ room_id: roomId,
92
+ workspace_id: finalWorkspaceId,
93
+ config,
94
+ recovery_config: recoveryConfig
95
+ };
96
+ const response = await this.fetchApi<CreateRoomResponse>(`/workspaces/${finalWorkspaceId}/rooms`, {
97
+ method: 'POST',
98
+ headers: { 'Content-Type': 'application/json' },
99
+ body: JSON.stringify(payload),
100
+ });
101
+ return { workspaceId: response.workspace_id, roomId: response.room_id };
102
+ }
103
+
104
+ async deleteRoom(workspaceId: string, roomId: string): Promise<boolean> {
105
+ try {
106
+ const response = await this.fetchApi<DeleteRoomResponse>(`/workspaces/${workspaceId}/rooms/${roomId}`, {
107
+ method: 'DELETE',
108
+ });
109
+ return response.success;
110
+ } catch (error) {
111
+ if (error instanceof Error && error.message.includes('404')) {
112
+ return false;
113
+ }
114
+ throw error;
115
+ }
116
+ }
117
+
118
+ async getRoomState(workspaceId: string, roomId: string): Promise<RoomState> {
119
+ const response = await this.fetchApi<GetRoomStateResponse>(`/workspaces/${workspaceId}/rooms/${roomId}/state`);
120
+ return response.state;
121
+ }
122
+
123
+ async getRoomInfo(workspaceId: string, roomId: string): Promise<RoomInfo> {
124
+ const response = await this.fetchApi<GetRoomResponse>(`/workspaces/${workspaceId}/rooms/${roomId}`);
125
+ return response.room;
126
+ }
127
+
128
+ // ============= WEBRTC SIGNALING =============
129
+
130
+ async sendWebRTCSignal(workspaceId: string, roomId: string, clientId: string, message: RTCSessionDescriptionInit | RTCIceCandidateInit | Record<string, unknown>): Promise<WebRTCSignalResponse> {
131
+ const request: WebRTCSignalRequest = { client_id: clientId, message };
132
+ return this.fetchApi<WebRTCSignalResponse>(`/workspaces/${workspaceId}/rooms/${roomId}/webrtc/signal`, {
133
+ method: 'POST',
134
+ headers: { 'Content-Type': 'application/json' },
135
+ body: JSON.stringify(request),
136
+ });
137
+ }
138
+
139
+ // ============= WEBSOCKET CONNECTION =============
140
+
141
+ async connectToRoom(
142
+ workspaceId: string,
143
+ roomId: string,
144
+ role: ParticipantRole,
145
+ participantId?: string
146
+ ): Promise<boolean> {
147
+ if (this.connected) {
148
+ await this.disconnect();
149
+ }
150
+
151
+ this.workspaceId = workspaceId;
152
+ this.roomId = roomId;
153
+ this.role = role;
154
+ this.participantId = participantId || `${role}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
155
+
156
+ // Convert HTTP URL to WebSocket URL
157
+ const wsUrl = this.baseUrl
158
+ .replace(/^http/, 'ws')
159
+ .replace(/^https/, 'wss');
160
+ const wsEndpoint = `${wsUrl}/video/workspaces/${workspaceId}/rooms/${roomId}/ws`;
161
+
162
+ try {
163
+ this.websocket = new WebSocket(wsEndpoint);
164
+
165
+ // Set up WebSocket event handlers
166
+ return new Promise((resolve, reject) => {
167
+ const timeout = setTimeout(() => {
168
+ reject(new Error('Connection timeout'));
169
+ }, this.options.timeout || 5000);
170
+
171
+ this.websocket!.onopen = () => {
172
+ clearTimeout(timeout);
173
+ this.sendJoinMessage();
174
+ };
175
+
176
+ this.websocket!.onmessage = (event) => {
177
+ try {
178
+ const message: WebSocketMessage = JSON.parse(event.data);
179
+ this.handleMessage(message);
180
+
181
+ // Handle initial connection responses
182
+ if (message.type === 'joined') {
183
+ this.connected = true;
184
+ this.onConnectedCallback?.();
185
+ this.emit('connected');
186
+ resolve(true);
187
+ } else if (message.type === 'error') {
188
+ this.handleError(message.message);
189
+ resolve(false);
190
+ }
191
+ } catch (error) {
192
+ console.error('Failed to parse WebSocket message:', error);
193
+ }
194
+ };
195
+
196
+ this.websocket!.onerror = (error) => {
197
+ clearTimeout(timeout);
198
+ console.error('WebSocket error:', error);
199
+ this.handleError('WebSocket connection error');
200
+ reject(error);
201
+ };
202
+
203
+ this.websocket!.onclose = () => {
204
+ clearTimeout(timeout);
205
+ this.connected = false;
206
+ this.onDisconnectedCallback?.();
207
+ this.emit('disconnected');
208
+ };
209
+ });
210
+ } catch (error) {
211
+ console.error('Failed to connect to room:', error);
212
+ return false;
213
+ }
214
+ }
215
+
216
+ async disconnect(): Promise<void> {
217
+ // Close WebRTC connection
218
+ if (this.peerConnection) {
219
+ this.peerConnection.close();
220
+ this.peerConnection = null;
221
+ }
222
+
223
+ // Stop local streams
224
+ if (this.localStream) {
225
+ this.localStream.getTracks().forEach(track => track.stop());
226
+ this.localStream = null;
227
+ }
228
+
229
+ // Close WebSocket
230
+ if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
231
+ this.websocket.close();
232
+ }
233
+
234
+ this.websocket = null;
235
+ this.remoteStream = null;
236
+ this.connected = false;
237
+ this.workspaceId = null;
238
+ this.roomId = null;
239
+ this.role = null;
240
+ this.participantId = null;
241
+
242
+ this.onDisconnectedCallback?.();
243
+ this.emit('disconnected');
244
+ }
245
+
246
+ // ============= WEBRTC METHODS =============
247
+
248
+ createPeerConnection(): RTCPeerConnection {
249
+ const config: RTCConfiguration = {
250
+ iceServers: this.webrtcConfig.iceServers || [
251
+ { urls: 'stun:stun.l.google.com:19302' }
252
+ ]
253
+ };
254
+
255
+ this.peerConnection = new RTCPeerConnection(config);
256
+
257
+ // Connection state changes
258
+ this.peerConnection.onconnectionstatechange = () => {
259
+ const state = this.peerConnection?.connectionState;
260
+ console.info(`🔌 WebRTC connection state: ${state}`);
261
+ };
262
+
263
+ // ICE connection state
264
+ this.peerConnection.oniceconnectionstatechange = () => {
265
+ const state = this.peerConnection?.iceConnectionState;
266
+ console.info(`🧊 ICE connection state: ${state}`);
267
+ };
268
+
269
+ // ICE candidate handling
270
+ this.peerConnection.onicecandidate = (event: RTCPeerConnectionIceEvent) => {
271
+ if (event.candidate && this.workspaceId && this.roomId && this.participantId) {
272
+ // Send ICE candidate via signaling
273
+ this.sendWebRTCSignal(this.workspaceId, this.roomId, this.participantId, {
274
+ type: 'ice',
275
+ candidate: event.candidate.toJSON()
276
+ } as Record<string, unknown>);
277
+ }
278
+ };
279
+
280
+ // Handle remote stream
281
+ this.peerConnection.ontrack = (event: RTCTrackEvent) => {
282
+ console.info('📺 Received remote track:', event.track.kind);
283
+ this.remoteStream = event.streams[0] || null;
284
+ this.emit('remoteStream', this.remoteStream);
285
+ };
286
+
287
+ return this.peerConnection;
288
+ }
289
+
290
+ async createOffer(): Promise<RTCSessionDescriptionInit> {
291
+ if (!this.peerConnection) {
292
+ throw new Error('Peer connection not created');
293
+ }
294
+
295
+ const offer = await this.peerConnection.createOffer();
296
+ await this.peerConnection.setLocalDescription(offer);
297
+
298
+ return offer;
299
+ }
300
+
301
+ async createAnswer(offer: RTCSessionDescriptionInit): Promise<RTCSessionDescriptionInit> {
302
+ if (!this.peerConnection) {
303
+ throw new Error('Peer connection not created');
304
+ }
305
+
306
+ await this.peerConnection.setRemoteDescription(offer);
307
+ const answer = await this.peerConnection.createAnswer();
308
+ await this.peerConnection.setLocalDescription(answer);
309
+
310
+ return answer;
311
+ }
312
+
313
+ async setRemoteDescription(description: RTCSessionDescriptionInit): Promise<void> {
314
+ if (!this.peerConnection) {
315
+ throw new Error('Peer connection not created');
316
+ }
317
+
318
+ await this.peerConnection.setRemoteDescription(description);
319
+ }
320
+
321
+ async addIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {
322
+ if (!this.peerConnection) {
323
+ throw new Error('Peer connection not created');
324
+ }
325
+
326
+ await this.peerConnection.addIceCandidate(candidate);
327
+ }
328
+
329
+ // ============= MEDIA METHODS =============
330
+
331
+ async startProducing(constraints?: MediaStreamConstraints): Promise<MediaStream> {
332
+ const mediaConstraints = constraints || this.webrtcConfig.constraints;
333
+
334
+ try {
335
+ this.localStream = await navigator.mediaDevices.getUserMedia(mediaConstraints);
336
+ return this.localStream;
337
+ } catch (error) {
338
+ throw new Error(`Failed to start video production: ${error}`);
339
+ }
340
+ }
341
+
342
+ async startScreenShare(): Promise<MediaStream> {
343
+ try {
344
+ this.localStream = await navigator.mediaDevices.getDisplayMedia({
345
+ video: {
346
+ width: this.webrtcConfig.resolution?.width || 1920,
347
+ height: this.webrtcConfig.resolution?.height || 1080,
348
+ frameRate: this.webrtcConfig.framerate || 30
349
+ },
350
+ audio: false
351
+ });
352
+
353
+ return this.localStream;
354
+ } catch (error) {
355
+ throw new Error(`Failed to start screen share: ${error}`);
356
+ }
357
+ }
358
+
359
+ stopProducing(): void {
360
+ if (this.localStream) {
361
+ this.localStream.getTracks().forEach(track => track.stop());
362
+ this.localStream = null;
363
+ }
364
+ }
365
+
366
+ // ============= GETTERS =============
367
+
368
+ getLocalStream(): MediaStream | null {
369
+ return this.localStream;
370
+ }
371
+
372
+ getRemoteStream(): MediaStream | null {
373
+ return this.remoteStream;
374
+ }
375
+
376
+ getPeerConnection(): RTCPeerConnection | null {
377
+ return this.peerConnection;
378
+ }
379
+
380
+ async getStats(): Promise<WebRTCStats | null> {
381
+ if (!this.peerConnection) {
382
+ return null;
383
+ }
384
+
385
+ const stats = await this.peerConnection.getStats();
386
+ return this.extractVideoStats(stats);
387
+ }
388
+
389
+ // ============= MESSAGE HANDLING =============
390
+
391
+ protected sendJoinMessage(): void {
392
+ if (!this.websocket || !this.participantId || !this.role) return;
393
+
394
+ const joinMessage: JoinMessage = {
395
+ participant_id: this.participantId,
396
+ role: this.role,
397
+ };
398
+
399
+ this.websocket.send(JSON.stringify(joinMessage));
400
+ }
401
+
402
+ protected handleMessage(message: WebSocketMessage): void {
403
+ switch (message.type) {
404
+ case 'joined':
405
+ console.log(`Successfully joined room ${message.room_id} as ${message.role}`);
406
+ break;
407
+ case 'heartbeat_ack':
408
+ console.debug('Heartbeat acknowledged');
409
+ break;
410
+ case 'error':
411
+ this.handleError(message.message);
412
+ break;
413
+ default:
414
+ // Let subclasses handle specific message types
415
+ this.handleRoleSpecificMessage(message);
416
+ }
417
+ }
418
+
419
+ protected handleRoleSpecificMessage(message: WebSocketMessage): void {
420
+ // To be overridden by subclasses
421
+ this.emit('message', message);
422
+ }
423
+
424
+ protected handleError(errorMessage: string): void {
425
+ console.error('Video client error:', errorMessage);
426
+ this.onErrorCallback?.(errorMessage);
427
+ this.emit('error', errorMessage);
428
+ }
429
+
430
+ // ============= UTILITY METHODS =============
431
+
432
+ async sendHeartbeat(): Promise<void> {
433
+ if (!this.connected || !this.websocket) return;
434
+
435
+ const message = { type: 'heartbeat' as const };
436
+ this.websocket.send(JSON.stringify(message));
437
+ }
438
+
439
+ isConnected(): boolean {
440
+ return this.connected;
441
+ }
442
+
443
+ getConnectionInfo(): ConnectionInfo {
444
+ return {
445
+ connected: this.connected,
446
+ workspace_id: this.workspaceId,
447
+ room_id: this.roomId,
448
+ role: this.role,
449
+ participant_id: this.participantId,
450
+ base_url: this.baseUrl,
451
+ };
452
+ }
453
+
454
+ // ============= EVENT CALLBACK SETTERS =============
455
+
456
+ onError(callback: ErrorCallback): void {
457
+ this.onErrorCallback = callback;
458
+ }
459
+
460
+ onConnected(callback: ConnectedCallback): void {
461
+ this.onConnectedCallback = callback;
462
+ }
463
+
464
+ onDisconnected(callback: DisconnectedCallback): void {
465
+ this.onDisconnectedCallback = callback;
466
+ }
467
+
468
+ // ============= PRIVATE HELPERS =============
469
+
470
+ private async fetchApi<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
471
+ const url = `${this.apiBase}${endpoint}`;
472
+ const response = await fetch(url, {
473
+ ...options,
474
+ signal: AbortSignal.timeout(this.options.timeout || 5000),
475
+ });
476
+
477
+ if (!response.ok) {
478
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
479
+ }
480
+
481
+ return response.json() as Promise<T>;
482
+ }
483
+
484
+ private extractVideoStats(stats: RTCStatsReport): WebRTCStats | null {
485
+ let inboundVideoStats: RTCInboundRtpStreamStats | null = null;
486
+ let outboundVideoStats: RTCOutboundRtpStreamStats | null = null;
487
+
488
+ stats.forEach((report) => {
489
+ if (report.type === 'inbound-rtp' && 'kind' in report && report.kind === 'video') {
490
+ inboundVideoStats = report as RTCInboundRtpStreamStats;
491
+ } else if (report.type === 'outbound-rtp' && 'kind' in report && report.kind === 'video') {
492
+ outboundVideoStats = report as RTCOutboundRtpStreamStats;
493
+ }
494
+ });
495
+
496
+ // Handle inbound stats (consumer)
497
+ if (inboundVideoStats) {
498
+ return {
499
+ videoBitsPerSecond: (inboundVideoStats as any).bytesReceived || 0,
500
+ framesPerSecond: (inboundVideoStats as any).framesPerSecond || 0,
501
+ frameWidth: (inboundVideoStats as any).frameWidth || 0,
502
+ frameHeight: (inboundVideoStats as any).frameHeight || 0,
503
+ packetsLost: (inboundVideoStats as any).packetsLost || 0,
504
+ totalPackets: (inboundVideoStats as any).packetsReceived || (inboundVideoStats as any).framesDecoded || 0
505
+ };
506
+ }
507
+
508
+ // Handle outbound stats (producer)
509
+ if (outboundVideoStats) {
510
+ return {
511
+ videoBitsPerSecond: (outboundVideoStats as any).bytesSent || 0,
512
+ framesPerSecond: (outboundVideoStats as any).framesPerSecond || 0,
513
+ frameWidth: (outboundVideoStats as any).frameWidth || 0,
514
+ frameHeight: (outboundVideoStats as any).frameHeight || 0,
515
+ packetsLost: (outboundVideoStats as any).packetsLost || 0,
516
+ totalPackets: (outboundVideoStats as any).packetsSent || (outboundVideoStats as any).framesSent || 0
517
+ };
518
+ }
519
+
520
+ return null;
521
+ }
522
+
523
+ // ============= WORKSPACE HELPERS =============
524
+
525
+ protected generateWorkspaceId(): string {
526
+ // Generate a UUID-like workspace ID
527
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
528
+ const r = Math.random() * 16 | 0;
529
+ const v = c === 'x' ? r : (r & 0x3 | 0x8);
530
+ return v.toString(16);
531
+ });
532
+ }
533
+ }
client/js/src/video/factory.ts ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Factory functions for creating LeRobot Arena video clients
3
+ */
4
+
5
+ import { VideoProducer } from './producer.js';
6
+ import { VideoConsumer } from './consumer.js';
7
+ import type { ParticipantRole, ClientOptions } from './types.js';
8
+
9
+ /**
10
+ * Factory function to create the appropriate client based on role
11
+ */
12
+ export function createClient(
13
+ role: ParticipantRole,
14
+ baseUrl = 'http://localhost:8000',
15
+ options: ClientOptions = {}
16
+ ): VideoProducer | VideoConsumer {
17
+ if (role === 'producer') {
18
+ return new VideoProducer(baseUrl, options);
19
+ }
20
+ if (role === 'consumer') {
21
+ return new VideoConsumer(baseUrl, options);
22
+ }
23
+ throw new Error(`Invalid role: ${role}. Must be 'producer' or 'consumer'`);
24
+ }
25
+
26
+ /**
27
+ * Create and connect a producer client
28
+ */
29
+ export async function createProducerClient(
30
+ baseUrl = 'http://localhost:8000',
31
+ workspaceId?: string,
32
+ roomId?: string,
33
+ participantId?: string,
34
+ options: ClientOptions = {}
35
+ ): Promise<VideoProducer> {
36
+ const producer = new VideoProducer(baseUrl, options);
37
+
38
+ const roomData = await producer.createRoom(workspaceId, roomId);
39
+ const connected = await producer.connect(roomData.workspaceId, roomData.roomId, participantId);
40
+
41
+ if (!connected) {
42
+ throw new Error('Failed to connect as video producer');
43
+ }
44
+
45
+ return producer;
46
+ }
47
+
48
+ /**
49
+ * Create and connect a consumer client
50
+ */
51
+ export async function createConsumerClient(
52
+ workspaceId: string,
53
+ roomId: string,
54
+ baseUrl = 'http://localhost:8000',
55
+ participantId?: string,
56
+ options: ClientOptions = {}
57
+ ): Promise<VideoConsumer> {
58
+ const consumer = new VideoConsumer(baseUrl, options);
59
+ const connected = await consumer.connect(workspaceId, roomId, participantId);
60
+
61
+ if (!connected) {
62
+ throw new Error('Failed to connect as video consumer');
63
+ }
64
+
65
+ return consumer;
66
+ }
client/js/src/video/index.ts ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * LeRobot Arena Video Client - Main Module
3
+ *
4
+ * TypeScript/JavaScript client for video streaming and monitoring
5
+ */
6
+
7
+ // Export core classes
8
+ export { VideoClientCore } from './core.js';
9
+ export { VideoProducer } from './producer.js';
10
+ export { VideoConsumer } from './consumer.js';
11
+
12
+ // Export all types
13
+ export * from './types.js';
14
+
15
+ // Export factory functions for convenience
16
+ export { createProducerClient, createConsumerClient, createClient } from './factory.js';
client/js/src/video/producer.ts ADDED
@@ -0,0 +1,439 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Producer client for video streaming in LeRobot Arena
3
+ */
4
+
5
+ import { VideoClientCore } from './core.js';
6
+ import type {
7
+ WebSocketMessage,
8
+ VideoConfigUpdateMessage,
9
+ StreamStartedMessage,
10
+ StreamStoppedMessage,
11
+ StatusUpdateMessage,
12
+ StreamStatsMessage,
13
+ ClientOptions,
14
+ VideoConfig,
15
+ WebRTCAnswerMessage,
16
+ WebRTCIceMessage,
17
+ } from './types.js';
18
+
19
+ export class VideoProducer extends VideoClientCore {
20
+ // Multiple peer connections - one per consumer
21
+ private consumerConnections: Map<string, RTCPeerConnection> = new Map();
22
+
23
+ constructor(baseUrl = 'http://localhost:8000', options: ClientOptions = {}) {
24
+ super(baseUrl, options);
25
+ }
26
+
27
+ // ============= PRODUCER CONNECTION =============
28
+
29
+ async connect(workspaceId: string, roomId: string, participantId?: string): Promise<boolean> {
30
+ const success = await this.connectToRoom(workspaceId, roomId, 'producer', participantId);
31
+
32
+ if (success) {
33
+ // Listen for consumer join events to initiate WebRTC
34
+ this.on('consumer_joined', (consumerId: string) => {
35
+ console.info(`🎯 Consumer ${consumerId} joined, initiating WebRTC...`);
36
+ this.initiateWebRTCWithConsumer(consumerId);
37
+ });
38
+
39
+ // Also check for existing consumers and initiate connections after a delay
40
+ setTimeout(() => this.connectToExistingConsumers(), 1000);
41
+ }
42
+
43
+ return success;
44
+ }
45
+
46
+ private async connectToExistingConsumers(): Promise<void> {
47
+ if (!this.workspaceId || !this.roomId) return;
48
+
49
+ try {
50
+ const roomInfo = await this.getRoomInfo(this.workspaceId, this.roomId);
51
+ for (const consumerId of roomInfo.participants.consumers) {
52
+ if (!this.consumerConnections.has(consumerId)) {
53
+ console.info(`🔄 Connecting to existing consumer ${consumerId}`);
54
+ await this.initiateWebRTCWithConsumer(consumerId);
55
+ }
56
+ }
57
+ } catch (error) {
58
+ console.error('Failed to connect to existing consumers:', error);
59
+ }
60
+ }
61
+
62
+ private createPeerConnectionForConsumer(consumerId: string): RTCPeerConnection {
63
+ const config: RTCConfiguration = {
64
+ iceServers: this.webrtcConfig.iceServers || [
65
+ { urls: 'stun:stun.l.google.com:19302' }
66
+ ]
67
+ };
68
+
69
+ const peerConnection = new RTCPeerConnection(config);
70
+
71
+ // Add local stream tracks to this connection
72
+ if (this.localStream) {
73
+ this.localStream.getTracks().forEach(track => {
74
+ peerConnection.addTrack(track, this.localStream!);
75
+ });
76
+ }
77
+
78
+ // Connection state changes
79
+ peerConnection.onconnectionstatechange = () => {
80
+ const state = peerConnection.connectionState;
81
+ console.info(`🔌 WebRTC connection state for ${consumerId}: ${state}`);
82
+
83
+ if (state === 'failed' || state === 'disconnected') {
84
+ console.warn(`Connection to ${consumerId} failed, attempting restart...`);
85
+ setTimeout(() => this.restartConnectionToConsumer(consumerId), 2000);
86
+ }
87
+ };
88
+
89
+ // ICE connection state
90
+ peerConnection.oniceconnectionstatechange = () => {
91
+ const state = peerConnection.iceConnectionState;
92
+ console.info(`🧊 ICE connection state for ${consumerId}: ${state}`);
93
+ };
94
+
95
+ // ICE candidate handling for this specific consumer
96
+ peerConnection.onicecandidate = (event) => {
97
+ if (event.candidate && this.workspaceId && this.roomId && this.participantId) {
98
+ this.sendWebRTCSignal(this.workspaceId, this.roomId, this.participantId, {
99
+ type: 'ice',
100
+ candidate: event.candidate.toJSON(),
101
+ target_consumer: consumerId,
102
+ } as Record<string, unknown>);
103
+ }
104
+ };
105
+
106
+ // Store the connection
107
+ this.consumerConnections.set(consumerId, peerConnection);
108
+
109
+ return peerConnection;
110
+ }
111
+
112
+ private async restartConnectionToConsumer(consumerId: string): Promise<void> {
113
+ console.info(`🔄 Restarting connection to consumer ${consumerId}`);
114
+ await this.initiateWebRTCWithConsumer(consumerId);
115
+ }
116
+
117
+ private handleConsumerLeft(consumerId: string): void {
118
+ const peerConnection = this.consumerConnections.get(consumerId);
119
+ if (peerConnection) {
120
+ peerConnection.close();
121
+ this.consumerConnections.delete(consumerId);
122
+ console.info(`🧹 Cleaned up peer connection for consumer ${consumerId}`);
123
+ }
124
+ }
125
+
126
+ private async restartConnectionsWithNewStream(stream: MediaStream): Promise<void> {
127
+ console.info('🔄 Restarting connections with new stream...');
128
+
129
+ // Close all existing connections
130
+ for (const [consumerId, peerConnection] of this.consumerConnections) {
131
+ peerConnection.close();
132
+ console.info(`🧹 Closed existing connection to consumer ${consumerId}`);
133
+ }
134
+ this.consumerConnections.clear();
135
+
136
+ // Get current consumers and restart connections
137
+ try {
138
+ if (this.workspaceId && this.roomId) {
139
+ const roomInfo = await this.getRoomInfo(this.workspaceId, this.roomId);
140
+ for (const consumerId of roomInfo.participants.consumers) {
141
+ console.info(`🔄 Creating new connection to consumer ${consumerId}...`);
142
+ await this.initiateWebRTCWithConsumer(consumerId);
143
+ }
144
+ }
145
+ } catch (error) {
146
+ console.error('Failed to restart connections:', error);
147
+ }
148
+ }
149
+
150
+ // ============= PRODUCER METHODS =============
151
+
152
+ async startCamera(constraints?: MediaStreamConstraints): Promise<MediaStream> {
153
+ if (!this.connected) {
154
+ throw new Error('Must be connected to start camera');
155
+ }
156
+
157
+ const stream = await this.startProducing(constraints);
158
+
159
+ // Store the stream and restart connections with new tracks
160
+ this.localStream = stream;
161
+ await this.restartConnectionsWithNewStream(stream);
162
+
163
+ // Notify about stream start
164
+ this.notifyStreamStarted(stream);
165
+
166
+ return stream;
167
+ }
168
+
169
+ override async startScreenShare(): Promise<MediaStream> {
170
+ if (!this.connected) {
171
+ throw new Error('Must be connected to start screen share');
172
+ }
173
+
174
+ const stream = await super.startScreenShare();
175
+
176
+ // Store the stream and restart connections with new tracks
177
+ this.localStream = stream;
178
+ await this.restartConnectionsWithNewStream(stream);
179
+
180
+ // Notify about stream start
181
+ this.notifyStreamStarted(stream);
182
+
183
+ return stream;
184
+ }
185
+
186
+ async stopStreaming(): Promise<void> {
187
+ if (!this.connected || !this.websocket) {
188
+ throw new Error('Must be connected to stop streaming');
189
+ }
190
+
191
+ // Close all consumer connections
192
+ for (const [consumerId, peerConnection] of this.consumerConnections) {
193
+ peerConnection.close();
194
+ console.info(`🧹 Closed connection to consumer ${consumerId}`);
195
+ }
196
+ this.consumerConnections.clear();
197
+
198
+ // Stop local stream
199
+ this.stopProducing();
200
+
201
+ // Notify about stream stop
202
+ this.notifyStreamStopped();
203
+ }
204
+
205
+ async updateVideoConfig(config: VideoConfig): Promise<void> {
206
+ if (!this.connected || !this.websocket) {
207
+ throw new Error('Must be connected to update video config');
208
+ }
209
+
210
+ const message: VideoConfigUpdateMessage = {
211
+ type: 'video_config_update',
212
+ config,
213
+ timestamp: new Date().toISOString(),
214
+ };
215
+
216
+ this.websocket.send(JSON.stringify(message));
217
+ }
218
+
219
+ async sendEmergencyStop(reason = 'Emergency stop'): Promise<void> {
220
+ if (!this.connected || !this.websocket) {
221
+ throw new Error('Must be connected to send emergency stop');
222
+ }
223
+
224
+ const message = {
225
+ type: 'emergency_stop' as const,
226
+ reason,
227
+ timestamp: new Date().toISOString(),
228
+ };
229
+
230
+ this.websocket.send(JSON.stringify(message));
231
+ }
232
+
233
+ // ============= WEBRTC NEGOTIATION =============
234
+
235
+ async initiateWebRTCWithConsumer(consumerId: string): Promise<void> {
236
+ if (!this.workspaceId || !this.roomId || !this.participantId) {
237
+ console.warn('WebRTC not ready, skipping negotiation with consumer');
238
+ return;
239
+ }
240
+
241
+ // Clean up existing connection if any
242
+ if (this.consumerConnections.has(consumerId)) {
243
+ const existingConn = this.consumerConnections.get(consumerId);
244
+ existingConn?.close();
245
+ this.consumerConnections.delete(consumerId);
246
+ }
247
+
248
+ try {
249
+ console.info(`🔄 Creating WebRTC offer for consumer ${consumerId}...`);
250
+
251
+ // Create a new peer connection specifically for this consumer
252
+ const peerConnection = this.createPeerConnectionForConsumer(consumerId);
253
+
254
+ // Create offer with this consumer's peer connection
255
+ const offer = await peerConnection.createOffer();
256
+ await peerConnection.setLocalDescription(offer);
257
+
258
+ console.info(`📤 Sending WebRTC offer to consumer ${consumerId}...`);
259
+
260
+ // Send offer to server/consumer
261
+ await this.sendWebRTCSignal(this.workspaceId, this.roomId, this.participantId, {
262
+ type: 'offer',
263
+ sdp: offer.sdp,
264
+ target_consumer: consumerId,
265
+ } as Record<string, unknown>);
266
+
267
+ console.info(`✅ WebRTC offer sent to consumer ${consumerId}`);
268
+ } catch (error) {
269
+ console.error(`Failed to initiate WebRTC with consumer ${consumerId}:`, error);
270
+ }
271
+ }
272
+
273
+ private async handleWebRTCAnswer(message: WebRTCAnswerMessage): Promise<void> {
274
+ try {
275
+ const consumerId = message.from_consumer;
276
+ console.info(`📥 Received WebRTC answer from consumer ${consumerId}`);
277
+
278
+ const peerConnection = this.consumerConnections.get(consumerId);
279
+ if (!peerConnection) {
280
+ console.warn(`No peer connection found for consumer ${consumerId}`);
281
+ return;
282
+ }
283
+
284
+ // Set remote description on the correct peer connection
285
+ const answer = new RTCSessionDescription({
286
+ type: 'answer',
287
+ sdp: message.answer.sdp
288
+ });
289
+
290
+ await peerConnection.setRemoteDescription(answer);
291
+
292
+ console.info(`✅ WebRTC negotiation completed with consumer ${consumerId}`);
293
+ } catch (error) {
294
+ console.error(`Failed to handle WebRTC answer from ${message.from_consumer}:`, error);
295
+ this.handleError(`Failed to handle WebRTC answer: ${error}`);
296
+ }
297
+ }
298
+
299
+ private async handleWebRTCIce(message: WebRTCIceMessage): Promise<void> {
300
+ try {
301
+ const consumerId = message.from_consumer;
302
+ if (!consumerId) {
303
+ console.warn('No consumer ID in ICE message');
304
+ return;
305
+ }
306
+
307
+ const peerConnection = this.consumerConnections.get(consumerId);
308
+ if (!peerConnection) {
309
+ console.warn(`No peer connection found for consumer ${consumerId}`);
310
+ return;
311
+ }
312
+
313
+ console.info(`📥 Received WebRTC ICE from consumer ${consumerId}`);
314
+
315
+ // Add ICE candidate to the correct peer connection
316
+ const candidate = new RTCIceCandidate(message.candidate);
317
+ await peerConnection.addIceCandidate(candidate);
318
+
319
+ console.info(`✅ WebRTC ICE handled with consumer ${consumerId}`);
320
+ } catch (error) {
321
+ console.error(`Failed to handle WebRTC ICE from ${message.from_consumer}:`, error);
322
+ this.handleError(`Failed to handle WebRTC ICE: ${error}`);
323
+ }
324
+ }
325
+
326
+ // ============= MESSAGE HANDLING =============
327
+
328
+ protected override handleRoleSpecificMessage(message: WebSocketMessage): void {
329
+ switch (message.type) {
330
+ case 'participant_joined':
331
+ // Check if this is a consumer joining
332
+ if (message.role === 'consumer' && message.participant_id !== this.participantId) {
333
+ console.info(`🎯 Consumer ${message.participant_id} joined room`);
334
+ this.emit('consumer_joined', message.participant_id);
335
+ }
336
+ break;
337
+ case 'participant_left':
338
+ // Check if this is a consumer leaving
339
+ if (message.role === 'consumer') {
340
+ console.info(`👋 Consumer ${message.participant_id} left room`);
341
+ this.handleConsumerLeft(message.participant_id);
342
+ }
343
+ break;
344
+ case 'webrtc_answer':
345
+ this.handleWebRTCAnswer(message as WebRTCAnswerMessage);
346
+ break;
347
+ case 'webrtc_ice':
348
+ this.handleWebRTCIce(message as WebRTCIceMessage);
349
+ break;
350
+ case 'status_update':
351
+ this.handleStatusUpdate(message as StatusUpdateMessage);
352
+ break;
353
+ case 'stream_stats':
354
+ this.handleStreamStats(message as StreamStatsMessage);
355
+ break;
356
+ case 'emergency_stop':
357
+ console.warn(`🚨 Emergency stop: ${message.reason || 'Unknown reason'}`);
358
+ this.handleError(`Emergency stop: ${message.reason || 'Unknown reason'}`);
359
+ break;
360
+ case 'error':
361
+ console.error(`Server error: ${message.message}`);
362
+ this.handleError(message.message);
363
+ break;
364
+ default:
365
+ console.warn(`Unknown message type for producer: ${message.type}`);
366
+ }
367
+ }
368
+
369
+ private handleStatusUpdate(message: StatusUpdateMessage): void {
370
+ console.info(`📊 Status update: ${message.status}`, message.data);
371
+ this.emit('statusUpdate', message.status, message.data);
372
+ }
373
+
374
+ private handleStreamStats(message: StreamStatsMessage): void {
375
+ console.debug(`📈 Stream stats:`, message.stats);
376
+ this.emit('streamStats', message.stats);
377
+ }
378
+
379
+ // ============= UTILITY METHODS =============
380
+
381
+ private async notifyStreamStarted(stream: MediaStream): Promise<void> {
382
+ if (!this.websocket) return;
383
+
384
+ const message: StreamStartedMessage = {
385
+ type: 'stream_started',
386
+ config: {
387
+ resolution: this.webrtcConfig.resolution,
388
+ framerate: this.webrtcConfig.framerate,
389
+ bitrate: this.webrtcConfig.bitrate,
390
+ },
391
+ participant_id: this.participantId!,
392
+ timestamp: new Date().toISOString(),
393
+ };
394
+
395
+ this.websocket.send(JSON.stringify(message));
396
+ this.emit('streamStarted', stream);
397
+ }
398
+
399
+ private async notifyStreamStopped(): Promise<void> {
400
+ if (!this.websocket) return;
401
+
402
+ const message: StreamStoppedMessage = {
403
+ type: 'stream_stopped',
404
+ participant_id: this.participantId!,
405
+ timestamp: new Date().toISOString(),
406
+ };
407
+
408
+ this.websocket.send(JSON.stringify(message));
409
+ this.emit('streamStopped');
410
+ }
411
+
412
+ /**
413
+ * Create a room and automatically connect as producer
414
+ */
415
+ static async createAndConnect(
416
+ baseUrl = 'http://localhost:8000',
417
+ workspaceId?: string,
418
+ roomId?: string,
419
+ participantId?: string
420
+ ): Promise<VideoProducer> {
421
+ const producer = new VideoProducer(baseUrl);
422
+
423
+ const roomData = await producer.createRoom(workspaceId, roomId);
424
+ const connected = await producer.connect(roomData.workspaceId, roomData.roomId, participantId);
425
+
426
+ if (!connected) {
427
+ throw new Error('Failed to connect as video producer');
428
+ }
429
+
430
+ return producer;
431
+ }
432
+
433
+ /**
434
+ * Get the current room ID (useful when auto-created)
435
+ */
436
+ get currentRoomId(): string | null {
437
+ return this.roomId;
438
+ }
439
+ }
client/js/src/video/types.ts ADDED
@@ -0,0 +1,352 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Type definitions for LeRobot Arena Video Client
3
+ * ✅ Fully synchronized with server-side models.py
4
+ */
5
+
6
+ // ============= CORE TYPES =============
7
+
8
+ export type ParticipantRole = 'producer' | 'consumer';
9
+
10
+ export type MessageType =
11
+ | 'frame_update'
12
+ | 'video_config_update'
13
+ | 'stream_started'
14
+ | 'stream_stopped'
15
+ | 'recovery_triggered'
16
+ | 'heartbeat'
17
+ | 'heartbeat_ack'
18
+ | 'emergency_stop'
19
+ | 'joined'
20
+ | 'error'
21
+ | 'participant_joined'
22
+ | 'participant_left'
23
+ | 'webrtc_offer'
24
+ | 'webrtc_answer'
25
+ | 'webrtc_ice'
26
+ | 'status_update'
27
+ | 'stream_stats';
28
+
29
+ // ============= VIDEO CONFIGURATION =============
30
+
31
+ export interface VideoConfig {
32
+ encoding?: VideoEncoding;
33
+ resolution?: Resolution;
34
+ framerate?: number;
35
+ bitrate?: number;
36
+ quality?: number;
37
+ }
38
+
39
+ export interface Resolution {
40
+ width: number;
41
+ height: number;
42
+ }
43
+
44
+ export type VideoEncoding = 'jpeg' | 'h264' | 'vp8' | 'vp9';
45
+
46
+ export type RecoveryPolicy =
47
+ | 'freeze_last_frame'
48
+ | 'connection_info'
49
+ | 'black_screen'
50
+ | 'fade_to_black'
51
+ | 'overlay_status';
52
+
53
+ export interface RecoveryConfig {
54
+ frame_timeout_ms?: number;
55
+ max_frame_reuse_count?: number;
56
+ recovery_policy?: RecoveryPolicy;
57
+ fallback_policy?: RecoveryPolicy;
58
+ show_hold_indicators?: boolean;
59
+ info_frame_bg_color?: [number, number, number];
60
+ info_frame_text_color?: [number, number, number];
61
+ fade_intensity?: number;
62
+ overlay_opacity?: number;
63
+ }
64
+
65
+ // ============= DATA STRUCTURES =============
66
+
67
+ export interface FrameData {
68
+ data: ArrayBuffer;
69
+ metadata?: Record<string, unknown>;
70
+ }
71
+
72
+ export interface StreamStats {
73
+ stream_id: string;
74
+ duration_seconds: number;
75
+ frame_count: number;
76
+ total_bytes: number;
77
+ average_fps: number;
78
+ average_bitrate: number;
79
+ }
80
+
81
+ export interface ParticipantInfo {
82
+ producer: string | null;
83
+ consumers: string[];
84
+ total: number;
85
+ }
86
+
87
+ export interface RoomInfo {
88
+ id: string;
89
+ workspace_id: string;
90
+ participants: ParticipantInfo;
91
+ frame_count: number;
92
+ config: VideoConfig;
93
+ has_producer: boolean;
94
+ active_consumers: number;
95
+ }
96
+
97
+ export interface RoomState {
98
+ room_id: string;
99
+ workspace_id: string;
100
+ participants: ParticipantInfo;
101
+ frame_count: number;
102
+ last_frame_time: string | null;
103
+ current_config: VideoConfig;
104
+ timestamp: string;
105
+ }
106
+
107
+ export interface ConnectionInfo {
108
+ connected: boolean;
109
+ workspace_id: string | null;
110
+ room_id: string | null;
111
+ role: ParticipantRole | null;
112
+ participant_id: string | null;
113
+ base_url: string;
114
+ }
115
+
116
+ // ============= MESSAGE TYPES =============
117
+
118
+ export interface BaseMessage {
119
+ type: MessageType;
120
+ timestamp?: string;
121
+ }
122
+
123
+ export interface FrameUpdateMessage extends BaseMessage {
124
+ type: 'frame_update';
125
+ data: ArrayBuffer;
126
+ metadata?: Record<string, unknown>;
127
+ }
128
+
129
+ export interface VideoConfigUpdateMessage extends BaseMessage {
130
+ type: 'video_config_update';
131
+ config: VideoConfig;
132
+ source?: string;
133
+ }
134
+
135
+ export interface StreamStartedMessage extends BaseMessage {
136
+ type: 'stream_started';
137
+ config: VideoConfig;
138
+ participant_id: string;
139
+ }
140
+
141
+ export interface StreamStoppedMessage extends BaseMessage {
142
+ type: 'stream_stopped';
143
+ participant_id: string;
144
+ reason?: string;
145
+ }
146
+
147
+ export interface RecoveryTriggeredMessage extends BaseMessage {
148
+ type: 'recovery_triggered';
149
+ policy: RecoveryPolicy;
150
+ reason: string;
151
+ }
152
+
153
+ export interface HeartbeatMessage extends BaseMessage {
154
+ type: 'heartbeat';
155
+ }
156
+
157
+ export interface HeartbeatAckMessage extends BaseMessage {
158
+ type: 'heartbeat_ack';
159
+ }
160
+
161
+ export interface EmergencyStopMessage extends BaseMessage {
162
+ type: 'emergency_stop';
163
+ reason: string;
164
+ source?: string;
165
+ }
166
+
167
+ export interface JoinedMessage extends BaseMessage {
168
+ type: 'joined';
169
+ room_id: string;
170
+ role: ParticipantRole;
171
+ }
172
+
173
+ export interface ErrorMessage extends BaseMessage {
174
+ type: 'error';
175
+ message: string;
176
+ code?: string;
177
+ }
178
+
179
+ export interface ParticipantJoinedMessage extends BaseMessage {
180
+ type: 'participant_joined';
181
+ room_id: string;
182
+ participant_id: string;
183
+ role: ParticipantRole;
184
+ }
185
+
186
+ export interface ParticipantLeftMessage extends BaseMessage {
187
+ type: 'participant_left';
188
+ room_id: string;
189
+ participant_id: string;
190
+ role: ParticipantRole;
191
+ }
192
+
193
+ export interface WebRTCOfferMessage extends BaseMessage {
194
+ type: 'webrtc_offer';
195
+ offer: RTCSessionDescriptionInit;
196
+ from_producer: string;
197
+ }
198
+
199
+ export interface WebRTCAnswerMessage extends BaseMessage {
200
+ type: 'webrtc_answer';
201
+ answer: RTCSessionDescriptionInit;
202
+ from_consumer: string;
203
+ }
204
+
205
+ export interface WebRTCIceMessage extends BaseMessage {
206
+ type: 'webrtc_ice';
207
+ candidate: RTCIceCandidateInit;
208
+ from_producer?: string;
209
+ from_consumer?: string;
210
+ }
211
+
212
+ export interface StatusUpdateMessage extends BaseMessage {
213
+ type: 'status_update';
214
+ status: string;
215
+ data?: Record<string, unknown>;
216
+ }
217
+
218
+ export interface StreamStatsMessage extends BaseMessage {
219
+ type: 'stream_stats';
220
+ stats: StreamStats;
221
+ }
222
+
223
+ export type WebSocketMessage =
224
+ | FrameUpdateMessage
225
+ | VideoConfigUpdateMessage
226
+ | StreamStartedMessage
227
+ | StreamStoppedMessage
228
+ | RecoveryTriggeredMessage
229
+ | HeartbeatMessage
230
+ | HeartbeatAckMessage
231
+ | EmergencyStopMessage
232
+ | JoinedMessage
233
+ | ErrorMessage
234
+ | ParticipantJoinedMessage
235
+ | ParticipantLeftMessage
236
+ | WebRTCOfferMessage
237
+ | WebRTCAnswerMessage
238
+ | WebRTCIceMessage
239
+ | StatusUpdateMessage
240
+ | StreamStatsMessage;
241
+
242
+ // ============= API RESPONSE TYPES =============
243
+
244
+ export interface ApiResponse<T = unknown> {
245
+ success: boolean;
246
+ data?: T;
247
+ error?: string;
248
+ message?: string;
249
+ }
250
+
251
+ export interface ListRoomsResponse {
252
+ success: boolean;
253
+ workspace_id: string;
254
+ rooms: RoomInfo[];
255
+ total: number;
256
+ }
257
+
258
+ export interface CreateRoomResponse {
259
+ success: boolean;
260
+ workspace_id: string;
261
+ room_id: string;
262
+ message: string;
263
+ }
264
+
265
+ export interface GetRoomResponse {
266
+ success: boolean;
267
+ workspace_id: string;
268
+ room: RoomInfo;
269
+ }
270
+
271
+ export interface GetRoomStateResponse {
272
+ success: boolean;
273
+ workspace_id: string;
274
+ state: RoomState;
275
+ }
276
+
277
+ export interface DeleteRoomResponse {
278
+ success: boolean;
279
+ workspace_id: string;
280
+ message: string;
281
+ }
282
+
283
+ export interface WebRTCSignalResponse {
284
+ success: boolean;
285
+ workspace_id: string;
286
+ response?: RTCSessionDescriptionInit | RTCIceCandidateInit;
287
+ message?: string;
288
+ }
289
+
290
+ // ============= REQUEST TYPES =============
291
+
292
+ export interface CreateRoomRequest {
293
+ room_id?: string;
294
+ workspace_id?: string;
295
+ name?: string;
296
+ config?: VideoConfig;
297
+ recovery_config?: RecoveryConfig;
298
+ max_consumers?: number;
299
+ }
300
+
301
+ export interface WebRTCSignalRequest {
302
+ client_id: string;
303
+ message: RTCSessionDescriptionInit | RTCIceCandidateInit | Record<string, unknown>;
304
+ }
305
+
306
+ export interface JoinMessage {
307
+ participant_id: string;
308
+ role: ParticipantRole;
309
+ }
310
+
311
+ // ============= WEBRTC TYPES =============
312
+
313
+ export interface WebRTCConfig {
314
+ iceServers?: RTCIceServer[];
315
+ constraints?: MediaStreamConstraints;
316
+ bitrate?: number;
317
+ framerate?: number;
318
+ resolution?: Resolution;
319
+ codecPreferences?: string[];
320
+ }
321
+
322
+ export interface WebRTCStats {
323
+ videoBitsPerSecond: number;
324
+ framesPerSecond: number;
325
+ frameWidth: number;
326
+ frameHeight: number;
327
+ packetsLost: number;
328
+ totalPackets: number;
329
+ }
330
+
331
+ // ============= EVENT CALLBACK TYPES =============
332
+
333
+ export type FrameUpdateCallback = (frame: FrameData) => void;
334
+ export type VideoConfigUpdateCallback = (config: VideoConfig) => void;
335
+ export type StreamStartedCallback = (config: VideoConfig, participantId: string) => void;
336
+ export type StreamStoppedCallback = (participantId: string, reason?: string) => void;
337
+ export type RecoveryTriggeredCallback = (policy: RecoveryPolicy, reason: string) => void;
338
+ export type StatusUpdateCallback = (status: string, data?: Record<string, unknown>) => void;
339
+ export type StreamStatsCallback = (stats: StreamStats) => void;
340
+ export type ErrorCallback = (error: string) => void;
341
+ export type ConnectedCallback = () => void;
342
+ export type DisconnectedCallback = () => void;
343
+
344
+ // ============= CLIENT OPTIONS =============
345
+
346
+ export interface ClientOptions {
347
+ base_url?: string;
348
+ timeout?: number;
349
+ reconnect_attempts?: number;
350
+ heartbeat_interval?: number;
351
+ webrtc_config?: WebRTCConfig;
352
+ }
client/js/tsconfig.json ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ // Environment setup & latest features
4
+ "lib": ["ESNext", "DOM"],
5
+ "target": "ESNext",
6
+ "module": "Preserve",
7
+ "moduleDetection": "force",
8
+ "jsx": "react-jsx",
9
+ "allowJs": true,
10
+
11
+ // Enable Bun types
12
+ "types": ["bun-types"],
13
+
14
+ // Bundler mode (for Vite)
15
+ "moduleResolution": "bundler",
16
+ "allowImportingTsExtensions": true,
17
+ "verbatimModuleSyntax": true,
18
+ "noEmit": true,
19
+
20
+ // Best practices
21
+ "strict": true,
22
+ "skipLibCheck": true,
23
+ "noFallthroughCasesInSwitch": true,
24
+ "noUncheckedIndexedAccess": true,
25
+ "noImplicitOverride": true,
26
+
27
+ // Some stricter flags (disabled by default)
28
+ "noUnusedLocals": false,
29
+ "noUnusedParameters": false,
30
+ "noPropertyAccessFromIndexSignature": false
31
+ }
32
+ }
client/js/vite.config.ts ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite';
2
+ import { resolve } from 'path';
3
+ import dts from 'vite-plugin-dts';
4
+
5
+ export default defineConfig({
6
+ plugins: [
7
+ dts({
8
+ insertTypesEntry: true,
9
+ rollupTypes: true,
10
+ }),
11
+ ],
12
+ build: {
13
+ lib: {
14
+ entry: {
15
+ index: resolve('src/index.ts'),
16
+ video: resolve('src/video/index.ts'),
17
+ robotics: resolve('src/robotics/index.ts'),
18
+ },
19
+ formats: ['es'],
20
+ },
21
+ rollupOptions: {
22
+ external: ['eventemitter3'],
23
+ output: {
24
+ preserveModules: false,
25
+ exports: 'named',
26
+ },
27
+ },
28
+ target: 'esnext',
29
+ minify: false,
30
+ },
31
+ resolve: {
32
+ alias: {
33
+ '@': resolve('src'),
34
+ '@/video': resolve('src/video'),
35
+ '@/robotics': resolve('src/robotics'),
36
+ },
37
+ },
38
+ });
client/python/.DS_Store ADDED
Binary file (6.15 kB). View file
 
client/python/README.md ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # LeRobot Arena Python Client
2
+
3
+ Python client library for the LeRobot Arena robotics API with separate Producer and Consumer classes.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install -e .
9
+ ```
10
+
11
+ Or with development dependencies:
12
+
13
+ ```bash
14
+ pip install -e ".[dev]"
15
+ ```
16
+
17
+ ## Basic Usage
18
+
19
+ ### Producer (Controller) Example
20
+
21
+ ```python
22
+ import asyncio
23
+ from lerobot_arena_client import RoboticsProducer
24
+
25
+ async def main():
26
+ # Create producer client
27
+ producer = RoboticsProducer('http://localhost:8000')
28
+
29
+ # List available rooms
30
+ rooms = await producer.list_rooms()
31
+ print('Available rooms:', rooms)
32
+
33
+ # Create new room and connect
34
+ room_id = await producer.create_room()
35
+ await producer.connect(room_id)
36
+
37
+ # Send initial state
38
+ await producer.send_state_sync({
39
+ 'shoulder': 45.0,
40
+ 'elbow': -20.0
41
+ })
42
+
43
+ # Send joint updates (only changed values will be forwarded!)
44
+ await producer.send_joint_update([
45
+ {'name': 'shoulder', 'value': 45.0},
46
+ {'name': 'elbow', 'value': -20.0}
47
+ ])
48
+
49
+ # Handle errors
50
+ producer.on_error(lambda err: print(f'Error: {err}'))
51
+
52
+ # Disconnect
53
+ await producer.disconnect()
54
+
55
+ if __name__ == "__main__":
56
+ asyncio.run(main())
57
+ ```
58
+
59
+ ### Consumer (Robot Executor) Example
60
+
61
+ ```python
62
+ import asyncio
63
+ from lerobot_arena_client import RoboticsConsumer
64
+
65
+ async def main():
66
+ consumer = RoboticsConsumer('http://localhost:8000')
67
+
68
+ # Connect to existing room
69
+ room_id = "your-room-id"
70
+ await consumer.connect(room_id)
71
+
72
+ # Get initial state
73
+ initial_state = await consumer.get_state_sync()
74
+ print('Initial state:', initial_state)
75
+
76
+ # Set up event handlers
77
+ def on_state_sync(state):
78
+ print('State sync:', state)
79
+
80
+ def on_joint_update(joints):
81
+ print('Execute joints:', joints)
82
+ # Execute on actual robot hardware
83
+ for joint in joints:
84
+ print(f"Moving {joint['name']} to {joint['value']}")
85
+
86
+ def on_error(error):
87
+ print(f'Error: {error}')
88
+
89
+ # Register callbacks
90
+ consumer.on_state_sync(on_state_sync)
91
+ consumer.on_joint_update(on_joint_update)
92
+ consumer.on_error(on_error)
93
+
94
+ # Keep running
95
+ try:
96
+ await asyncio.sleep(60) # Run for 60 seconds
97
+ finally:
98
+ await consumer.disconnect()
99
+
100
+ if __name__ == "__main__":
101
+ asyncio.run(main())
102
+ ```
103
+
104
+ ### Factory Function Usage
105
+
106
+ ```python
107
+ import asyncio
108
+ from lerobot_arena_client import create_client
109
+
110
+ async def main():
111
+ # Create clients using factory function
112
+ producer = create_client("producer", "http://localhost:8000")
113
+ consumer = create_client("consumer", "http://localhost:8000")
114
+
115
+ # Or use convenience functions
116
+ from lerobot_arena_client import create_producer_client, create_consumer_client
117
+
118
+ # Quick producer setup (auto-creates room and connects)
119
+ producer = await create_producer_client('http://localhost:8000')
120
+ print(f"Producer connected to room: {producer.room_id}")
121
+
122
+ # Quick consumer setup (connects to existing room)
123
+ consumer = await create_consumer_client(producer.room_id, 'http://localhost:8000')
124
+
125
+ # Use context managers for automatic cleanup
126
+ async with RoboticsProducer('http://localhost:8000') as producer:
127
+ room_id = await producer.create_room()
128
+ await producer.connect(room_id)
129
+ await producer.send_state_sync({'joint1': 10.0})
130
+
131
+ if __name__ == "__main__":
132
+ asyncio.run(main())
133
+ ```
134
+
135
+ ### Advanced Example: Producer-Consumer Pair
136
+
137
+ ```python
138
+ import asyncio
139
+ from lerobot_arena_client import RoboticsProducer, RoboticsConsumer
140
+
141
+ async def run_producer(room_id: str):
142
+ async with RoboticsProducer() as producer:
143
+ await producer.connect(room_id)
144
+
145
+ # Simulate sending commands
146
+ for i in range(10):
147
+ await producer.send_state_sync({
148
+ 'joint1': i * 10.0,
149
+ 'joint2': i * -5.0
150
+ })
151
+ await asyncio.sleep(1)
152
+
153
+ async def run_consumer(room_id: str):
154
+ async with RoboticsConsumer() as consumer:
155
+ await consumer.connect(room_id)
156
+
157
+ def handle_joint_update(joints):
158
+ print(f"🤖 Executing: {joints}")
159
+ # Your robot control code here
160
+
161
+ consumer.on_joint_update(handle_joint_update)
162
+
163
+ # Keep listening
164
+ await asyncio.sleep(15)
165
+
166
+ async def main():
167
+ # Create room
168
+ producer = RoboticsProducer()
169
+ room_id = await producer.create_room()
170
+
171
+ # Run producer and consumer concurrently
172
+ await asyncio.gather(
173
+ run_producer(room_id),
174
+ run_consumer(room_id)
175
+ )
176
+
177
+ if __name__ == "__main__":
178
+ asyncio.run(main())
179
+ ```
180
+
181
+ ## API Reference
182
+
183
+ ### RoboticsProducer
184
+
185
+ **Connection Methods:**
186
+ - `connect(room_id, participant_id=None)` - Connect as producer to room
187
+
188
+ **Control Methods:**
189
+ - `send_joint_update(joints)` - Send joint updates
190
+ - `send_state_sync(state)` - Send state synchronization (dict format)
191
+ - `send_emergency_stop(reason)` - Send emergency stop
192
+
193
+ **Event Callbacks:**
194
+ - `on_error(callback)` - Set error callback
195
+ - `on_connected(callback)` - Set connection callback
196
+ - `on_disconnected(callback)` - Set disconnection callback
197
+
198
+ ### RoboticsConsumer
199
+
200
+ **Connection Methods:**
201
+ - `connect(room_id, participant_id=None)` - Connect as consumer to room
202
+
203
+ **State Methods:**
204
+ - `get_state_sync()` - Get current state synchronously
205
+
206
+ **Event Callbacks:**
207
+ - `on_state_sync(callback)` - Set state sync callback
208
+ - `on_joint_update(callback)` - Set joint update callback
209
+ - `on_error(callback)` - Set error callback
210
+ - `on_connected(callback)` - Set connection callback
211
+ - `on_disconnected(callback)` - Set disconnection callback
212
+
213
+ ### RoboticsClientCore (Base Class)
214
+
215
+ **REST API Methods:**
216
+ - `list_rooms()` - List all available rooms
217
+ - `create_room(room_id=None)` - Create a new room
218
+ - `delete_room(room_id)` - Delete a room
219
+ - `get_room_state(room_id)` - Get current room state
220
+ - `get_room_info(room_id)` - Get basic room information
221
+
222
+ **Utility Methods:**
223
+ - `send_heartbeat()` - Send heartbeat to server
224
+ - `is_connected()` - Check connection status
225
+ - `get_connection_info()` - Get connection details
226
+ - `disconnect()` - Disconnect from room
227
+
228
+ ### Factory Functions
229
+
230
+ - `create_client(role, base_url)` - Create client by role ("producer" or "consumer")
231
+ - `create_producer_client(base_url, room_id=None)` - Create connected producer
232
+ - `create_consumer_client(room_id, base_url)` - Create connected consumer
233
+
234
+ ## Requirements
235
+
236
+ - Python 3.12+
237
+ - aiohttp>=3.9.0
238
+ - websockets>=12.0
239
+
240
+ ## Migration from v1
241
+
242
+ The old `RoboticsClient` is still available for backward compatibility but is now an alias to `RoboticsClientCore`. For new code, use the specific `RoboticsProducer` or `RoboticsConsumer` classes for better type safety and cleaner APIs.
client/python/__pycache__/test_ai_camera.cpython-312-pytest-8.4.0.pyc ADDED
Binary file (181 Bytes). View file
 
client/python/examples/README.md ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # LeRobot Arena Examples
2
+
3
+ This directory contains example scripts demonstrating various aspects of the LeRobot Arena robotics system.
4
+
5
+ ## Prerequisites
6
+
7
+ Before running these examples, ensure you have:
8
+
9
+ 1. **Server running**: Start the LeRobot Arena server
10
+ ```bash
11
+ # From the server directory
12
+ uvicorn src.api.main:app --reload
13
+ ```
14
+
15
+ 2. **Dependencies installed**: Install the Python client
16
+ ```bash
17
+ # From client/python directory
18
+ pip install -e .
19
+ ```
20
+
21
+ ## Examples Overview
22
+
23
+ ### 1. `basic_producer.py`
24
+ **Basic Producer Usage**
25
+
26
+ Demonstrates core producer functionality:
27
+ - Creating a room
28
+ - Connecting as a producer
29
+ - Sending joint updates
30
+ - Sending state synchronization
31
+ - Basic error handling
32
+
33
+ ```bash
34
+ python examples/basic_producer.py
35
+ ```
36
+
37
+ ### 2. `basic_consumer.py`
38
+ **Basic Consumer Usage**
39
+
40
+ Shows how to connect as a consumer and receive data:
41
+ - Connecting to an existing room
42
+ - Setting up event callbacks
43
+ - Receiving joint updates and state sync
44
+ - Getting current room state
45
+
46
+ ```bash
47
+ python examples/basic_consumer.py
48
+ # You'll need to enter a room ID from a running producer
49
+ ```
50
+
51
+ ### 3. `room_management.py`
52
+ **Room Management Operations**
53
+
54
+ Demonstrates REST API operations:
55
+ - Listing all rooms
56
+ - Creating rooms (auto-generated and custom IDs)
57
+ - Getting room information and state
58
+ - Deleting rooms
59
+
60
+ ```bash
61
+ python examples/room_management.py
62
+ ```
63
+
64
+ ### 4. `producer_consumer_demo.py`
65
+ **Complete Producer-Consumer Demo**
66
+
67
+ Comprehensive demonstration featuring:
68
+ - Producer and multiple consumers working together
69
+ - Realistic robot movement simulation
70
+ - Emergency stop functionality
71
+ - State synchronization
72
+ - Connection management and cleanup
73
+
74
+ ```bash
75
+ python examples/producer_consumer_demo.py
76
+ ```
77
+
78
+ ### 5. `context_manager_example.py`
79
+ **Context Managers and Advanced Patterns**
80
+
81
+ Shows advanced usage patterns:
82
+ - Using clients as context managers for automatic cleanup
83
+ - Exception handling with proper resource management
84
+ - Factory functions for quick client setup
85
+ - Managing multiple clients
86
+
87
+ ```bash
88
+ python examples/context_manager_example.py
89
+ ```
90
+
91
+ ## Running Examples
92
+
93
+ ### Quick Start (All-in-One Demo)
94
+ For a complete demonstration, run:
95
+ ```bash
96
+ python examples/producer_consumer_demo.py
97
+ ```
98
+
99
+ ### Step-by-Step Testing
100
+ 1. **Start with room management**:
101
+ ```bash
102
+ python examples/room_management.py
103
+ ```
104
+
105
+ 2. **Run producer in one terminal**:
106
+ ```bash
107
+ python examples/basic_producer.py
108
+ ```
109
+
110
+ 3. **Run consumer in another terminal**:
111
+ ```bash
112
+ python examples/basic_consumer.py
113
+ # Use the room ID from the producer
114
+ ```
115
+
116
+ ### Advanced Examples
117
+ For more sophisticated patterns:
118
+ ```bash
119
+ python examples/context_manager_example.py
120
+ ```
121
+
122
+ ## Key Concepts Demonstrated
123
+
124
+ ### Producer Capabilities
125
+ - **Room Creation**: Creating and managing robotics rooms
126
+ - **Joint Control**: Sending individual joint position updates
127
+ - **State Synchronization**: Sending complete robot state
128
+ - **Emergency Stop**: Triggering safety stops
129
+ - **Error Handling**: Managing connection and communication errors
130
+
131
+ ### Consumer Capabilities
132
+ - **Real-time Updates**: Receiving joint position updates
133
+ - **State Monitoring**: Getting current robot state
134
+ - **Event Callbacks**: Responding to various message types
135
+ - **Multiple Consumers**: Supporting multiple clients per room
136
+
137
+ ### Connection Management
138
+ - **WebSocket Communication**: Real-time bidirectional communication
139
+ - **Auto-reconnection**: Handling connection failures
140
+ - **Resource Cleanup**: Proper disconnection and cleanup
141
+ - **Context Managers**: Automatic resource management
142
+
143
+ ### Error Handling
144
+ - **Connection Failures**: Graceful handling of network issues
145
+ - **Invalid Operations**: Handling invalid commands or states
146
+ - **Emergency Scenarios**: Safety system demonstrations
147
+ - **Resource Management**: Proper cleanup in error conditions
148
+
149
+ ## Example Output
150
+
151
+ When running the examples, you'll see detailed logging output showing:
152
+ - Connection status and events
153
+ - Data being sent and received
154
+ - Error conditions and handling
155
+ - Resource cleanup operations
156
+
157
+ Example log output:
158
+ ```
159
+ INFO:__main__:Created room: abc123-def456-ghi789
160
+ INFO:__main__:[Producer] Connected!
161
+ INFO:__main__:[visualizer] Joint update #1: 4 joints
162
+ INFO:__main__:[logger] Joint update #1: 4 joints
163
+ INFO:__main__:🚨 [Producer] Sending emergency stop!
164
+ INFO:__main__:[safety-monitor] ERROR: Emergency stop: Demo emergency stop
165
+ ```
166
+
167
+ ## Troubleshooting
168
+
169
+ ### Common Issues
170
+
171
+ 1. **Connection Failed**: Ensure the server is running on `http://localhost:8000`
172
+ 2. **Import Errors**: Make sure you've installed the client package (`pip install -e .`)
173
+ 3. **Room Not Found**: Check that the room ID exists (run `room_management.py` to see active rooms)
174
+ 4. **Permission Denied**: Only one producer per room is allowed
175
+
176
+ ### Debug Mode
177
+ Enable debug logging for more detailed output:
178
+ ```python
179
+ logging.basicConfig(level=logging.DEBUG)
180
+ ```
181
+
182
+ ## Next Steps
183
+
184
+ After running these examples, you can:
185
+ - Integrate the client into your own robotics applications
186
+ - Modify the examples for your specific robot hardware
187
+ - Build custom visualizers or control interfaces
188
+ - Implement safety monitoring systems
189
+ - Create automated testing scenarios
client/python/examples/__pycache__/test_ai_server_consumer.cpython-312-pytest-8.4.0.pyc ADDED
Binary file (9.71 kB). View file
 
client/python/examples/__pycache__/test_consumer_fix.cpython-312-pytest-8.4.0.pyc ADDED
Binary file (11.3 kB). View file
 
client/python/examples/basic_consumer.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Basic Consumer Example - LeRobot Arena
4
+
5
+ This example demonstrates:
6
+ - Connecting to an existing room as a consumer
7
+ - Receiving joint updates and state sync
8
+ - Setting up callbacks
9
+ - Getting current state
10
+ """
11
+
12
+ import asyncio
13
+ import logging
14
+
15
+ from lerobot_arena_client import RoboticsConsumer
16
+
17
+ # Setup logging
18
+ logging.basicConfig(level=logging.INFO)
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ async def main():
23
+ """Basic consumer example."""
24
+ # You need to provide an existing room ID
25
+ # You can get this from running basic_producer.py first
26
+ room_id = input("Enter room ID to connect to: ").strip()
27
+
28
+ if not room_id:
29
+ logger.error("Room ID is required!")
30
+ return
31
+
32
+ # Create consumer client
33
+ consumer = RoboticsConsumer("http://localhost:8000")
34
+
35
+ # Track received updates
36
+ received_updates = []
37
+ received_states = []
38
+
39
+ # Set up callbacks
40
+ def on_joint_update(joints):
41
+ logger.info(f"Received joint update: {joints}")
42
+ received_updates.append(joints)
43
+
44
+ def on_state_sync(state):
45
+ logger.info(f"Received state sync: {state}")
46
+ received_states.append(state)
47
+
48
+ def on_error(error_msg):
49
+ logger.error(f"Consumer error: {error_msg}")
50
+
51
+ def on_connected():
52
+ logger.info("Consumer connected!")
53
+
54
+ def on_disconnected():
55
+ logger.info("Consumer disconnected!")
56
+
57
+ # Register all callbacks
58
+ consumer.on_joint_update(on_joint_update)
59
+ consumer.on_state_sync(on_state_sync)
60
+ consumer.on_error(on_error)
61
+ consumer.on_connected(on_connected)
62
+ consumer.on_disconnected(on_disconnected)
63
+
64
+ try:
65
+ # Connect to the room
66
+ success = await consumer.connect(room_id)
67
+ if not success:
68
+ logger.error("Failed to connect to room!")
69
+ return
70
+
71
+ # Get initial state
72
+ initial_state = await consumer.get_state_sync()
73
+ logger.info(f"Initial state: {initial_state}")
74
+
75
+ # Listen for updates for 30 seconds
76
+ logger.info("Listening for updates for 30 seconds...")
77
+ await asyncio.sleep(30)
78
+
79
+ # Show summary
80
+ logger.info(f"Received {len(received_updates)} joint updates")
81
+ logger.info(f"Received {len(received_states)} state syncs")
82
+
83
+ except Exception as e:
84
+ logger.error(f"Error: {e}")
85
+ finally:
86
+ # Always disconnect
87
+ if consumer.is_connected():
88
+ await consumer.disconnect()
89
+
90
+
91
+ if __name__ == "__main__":
92
+ asyncio.run(main())
client/python/examples/basic_producer.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Basic Producer Example - LeRobot Arena
4
+
5
+ This example demonstrates:
6
+ - Creating a room
7
+ - Connecting as a producer
8
+ - Sending joint updates
9
+ - Basic error handling
10
+ """
11
+
12
+ import asyncio
13
+ import logging
14
+
15
+ from lerobot_arena_client import RoboticsProducer
16
+
17
+ # Setup logging
18
+ logging.basicConfig(level=logging.INFO)
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ async def main():
23
+ """Basic producer example."""
24
+ # Create producer client
25
+ producer = RoboticsProducer("http://localhost:8000")
26
+
27
+ # Set up error callback
28
+ def on_error(error_msg):
29
+ logger.error(f"Producer error: {error_msg}")
30
+
31
+ producer.on_error(on_error)
32
+
33
+ try:
34
+ # Create a room and connect
35
+ room_id = await producer.create_room()
36
+ logger.info(f"Created room: {room_id}")
37
+
38
+ # Connect as producer
39
+ success = await producer.connect(room_id)
40
+ if not success:
41
+ logger.error("Failed to connect!")
42
+ return
43
+
44
+ logger.info("Connected as producer!")
45
+
46
+ # Send some joint updates
47
+ joints = [
48
+ {"name": "shoulder", "value": 45.0},
49
+ {"name": "elbow", "value": -20.0},
50
+ {"name": "wrist", "value": 10.0},
51
+ ]
52
+
53
+ logger.info("Sending joint updates...")
54
+ await producer.send_joint_update(joints)
55
+
56
+ # Send state sync (converted to joint updates)
57
+ state = {"shoulder": 90.0, "elbow": -45.0, "wrist": 0.0}
58
+
59
+ logger.info("Sending state sync...")
60
+ await producer.send_state_sync(state)
61
+
62
+ # Keep alive for a bit
63
+ await asyncio.sleep(2)
64
+
65
+ logger.info("Example completed successfully!")
66
+
67
+ except Exception as e:
68
+ logger.error(f"Error: {e}")
69
+ finally:
70
+ # Always disconnect
71
+ if producer.is_connected():
72
+ await producer.disconnect()
73
+ logger.info("Disconnected")
74
+
75
+
76
+ if __name__ == "__main__":
77
+ asyncio.run(main())
client/python/examples/consumer_first_recorder.py ADDED
@@ -0,0 +1,319 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Consumer-First Video Recorder Example
4
+
5
+ This example demonstrates the "consumer-first" scenario where:
6
+ 1. Consumer creates a room and waits
7
+ 2. Producer joins later and starts streaming
8
+ 3. Consumer records exactly 10 seconds of video when frames arrive
9
+ 4. Saves the recorded video to disk
10
+
11
+ This tests the case where consumers are already waiting when producers start streaming.
12
+ """
13
+
14
+ import asyncio
15
+ import logging
16
+ import time
17
+ from pathlib import Path
18
+
19
+ import cv2
20
+ import numpy as np
21
+ from lerobot_arena_client.video import VideoConsumer
22
+
23
+ # Setup logging
24
+ logging.basicConfig(
25
+ level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
26
+ )
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ class VideoRecorder:
31
+ """Records video frames for a specific duration"""
32
+
33
+ def __init__(
34
+ self,
35
+ duration_seconds: float = 10.0,
36
+ output_dir: str = "./recordings",
37
+ fps: int = 30,
38
+ ):
39
+ self.duration_seconds = duration_seconds
40
+ self.output_dir = Path(output_dir)
41
+ self.output_dir.mkdir(exist_ok=True)
42
+ self.fps = fps
43
+
44
+ # Recording state
45
+ self.recording = False
46
+ self.start_time: float | None = None
47
+ self.frames: list[np.ndarray] = []
48
+ self.frame_count = 0
49
+ self.recording_complete = False
50
+
51
+ # Video writer for MP4 output
52
+ self.video_writer: cv2.VideoWriter | None = None
53
+ self.video_path: Path | None = None
54
+
55
+ def start_recording(self, width: int, height: int) -> None:
56
+ """Start recording with the given frame dimensions"""
57
+ if self.recording:
58
+ logger.warning("Recording already in progress")
59
+ return
60
+
61
+ self.recording = True
62
+ self.start_time = time.time()
63
+ self.frames = []
64
+ self.frame_count = 0
65
+ self.recording_complete = False
66
+
67
+ # Create video writer for MP4 output
68
+ timestamp = int(time.time())
69
+ self.video_path = self.output_dir / f"recording_{timestamp}.mp4"
70
+
71
+ # Define codec and create VideoWriter
72
+ fourcc = cv2.VideoWriter_fourcc(*"mp4v")
73
+ self.video_writer = cv2.VideoWriter(
74
+ str(self.video_path), fourcc, self.fps, (width, height)
75
+ )
76
+
77
+ logger.info(f"🎬 Started recording to {self.video_path}")
78
+ logger.info(f" Duration: {self.duration_seconds}s")
79
+ logger.info(f" Resolution: {width}x{height}")
80
+ logger.info(f" Target FPS: {self.fps}")
81
+
82
+ def add_frame(self, frame_data) -> bool:
83
+ """Add a frame to the recording. Returns True if recording is complete."""
84
+ if not self.recording or self.recording_complete:
85
+ return self.recording_complete
86
+
87
+ # Check if recording duration exceeded
88
+ if self.start_time and time.time() - self.start_time > self.duration_seconds:
89
+ self.stop_recording()
90
+ return True
91
+
92
+ try:
93
+ # Extract frame information
94
+ metadata = frame_data.metadata
95
+ width = metadata.get("width", 0)
96
+ height = metadata.get("height", 0)
97
+
98
+ # Convert bytes to numpy array (server sends RGB format)
99
+ frame_bytes = frame_data.data
100
+ img_rgb = np.frombuffer(frame_bytes, dtype=np.uint8).reshape((
101
+ height,
102
+ width,
103
+ 3,
104
+ ))
105
+
106
+ # Convert RGB to BGR for OpenCV
107
+ img_bgr = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2BGR)
108
+
109
+ # Write frame to video
110
+ if self.video_writer:
111
+ self.video_writer.write(img_bgr)
112
+
113
+ # Store frame in memory for backup
114
+ self.frames.append(img_bgr.copy())
115
+ self.frame_count += 1
116
+
117
+ # Progress logging
118
+ if self.frame_count % 30 == 0: # Every ~1 second at 30fps
119
+ elapsed = time.time() - self.start_time if self.start_time else 0
120
+ remaining = max(0, self.duration_seconds - elapsed)
121
+ logger.info(
122
+ f"🎬 Recording: {elapsed:.1f}s / {self.duration_seconds}s ({remaining:.1f}s remaining)"
123
+ )
124
+
125
+ except Exception as e:
126
+ logger.error(f"❌ Error adding frame to recording: {e}")
127
+
128
+ return False
129
+
130
+ def stop_recording(self) -> dict:
131
+ """Stop recording and save the video"""
132
+ if not self.recording:
133
+ logger.warning("No recording in progress")
134
+ return {}
135
+
136
+ self.recording = False
137
+ self.recording_complete = True
138
+
139
+ # Release video writer
140
+ if self.video_writer:
141
+ self.video_writer.release()
142
+ self.video_writer = None
143
+
144
+ # Calculate stats
145
+ end_time = time.time()
146
+ actual_duration = end_time - self.start_time if self.start_time else 0
147
+ actual_fps = self.frame_count / actual_duration if actual_duration > 0 else 0
148
+
149
+ stats = {
150
+ "duration": actual_duration,
151
+ "frame_count": self.frame_count,
152
+ "target_fps": self.fps,
153
+ "actual_fps": actual_fps,
154
+ "video_path": str(self.video_path) if self.video_path else None,
155
+ "frame_backup_count": len(self.frames),
156
+ }
157
+
158
+ logger.info("🎬 Recording completed!")
159
+ logger.info(f" Actual duration: {actual_duration:.1f}s")
160
+ logger.info(f" Frames recorded: {self.frame_count}")
161
+ logger.info(f" Actual FPS: {actual_fps:.1f}")
162
+ logger.info(f" Video saved to: {self.video_path}")
163
+
164
+ # Also save frame sequence as backup
165
+ if self.frames:
166
+ self._save_frame_sequence()
167
+
168
+ return stats
169
+
170
+ def _save_frame_sequence(self) -> None:
171
+ """Save individual frames as image sequence backup"""
172
+ frame_dir = self.output_dir / f"frames_{int(time.time())}"
173
+ frame_dir.mkdir(exist_ok=True)
174
+
175
+ for i, frame in enumerate(self.frames):
176
+ frame_path = frame_dir / f"frame_{i:04d}.jpg"
177
+ cv2.imwrite(str(frame_path), frame)
178
+
179
+ logger.info(f"📸 Saved {len(self.frames)} frames to {frame_dir}")
180
+
181
+
182
+ async def main():
183
+ """Main consumer-first recorder example"""
184
+ # Configuration
185
+ base_url = "http://localhost:8000"
186
+ recording_duration = 10.0 # Record for 10 seconds
187
+
188
+ logger.info("🎬 Consumer-First Video Recorder")
189
+ logger.info("=" * 50)
190
+ logger.info(f"Server: {base_url}")
191
+ logger.info(f"Recording duration: {recording_duration}s")
192
+ logger.info("")
193
+
194
+ # Create consumer
195
+ consumer = VideoConsumer(base_url)
196
+ recorder = VideoRecorder(duration_seconds=recording_duration)
197
+
198
+ # Track recording state
199
+ recording_started = False
200
+ recording_stats = {}
201
+
202
+ def handle_frame(frame_data):
203
+ """Handle received frame data"""
204
+ nonlocal recording_started, recording_stats
205
+
206
+ if not recording_started:
207
+ # Start recording on first frame
208
+ metadata = frame_data.metadata
209
+ width = metadata.get("width", 640)
210
+ height = metadata.get("height", 480)
211
+
212
+ recorder.start_recording(width, height)
213
+ recording_started = True
214
+
215
+ # Add frame to recording
216
+ is_complete = recorder.add_frame(frame_data)
217
+
218
+ if is_complete and not recording_stats:
219
+ recording_stats = recorder.stop_recording()
220
+
221
+ # Set up event handlers
222
+ consumer.on_frame_update(handle_frame)
223
+
224
+ def on_stream_started(config, producer_id):
225
+ logger.info(f"🚀 Producer {producer_id} started streaming!")
226
+ logger.info("🎬 Ready to record when frames arrive...")
227
+
228
+ def on_stream_stopped(producer_id, reason):
229
+ logger.info(f"⏹️ Producer {producer_id} stopped streaming")
230
+ if reason:
231
+ logger.info(f" Reason: {reason}")
232
+
233
+ consumer.on_stream_started(on_stream_started)
234
+ consumer.on_stream_stopped(on_stream_stopped)
235
+
236
+ try:
237
+ # Step 1: Create our own room
238
+ logger.info("🏗️ Creating video room...")
239
+ room_id = await consumer.create_room("consumer-first-test")
240
+ logger.info(f"✅ Created room: {room_id}")
241
+
242
+ # Step 2: Connect as consumer
243
+ logger.info("🔌 Connecting to room as consumer...")
244
+ connected = await consumer.connect(room_id)
245
+
246
+ if not connected:
247
+ logger.error("❌ Failed to connect to room")
248
+ return
249
+
250
+ logger.info("✅ Connected as consumer successfully")
251
+
252
+ # Step 3: Start receiving (prepare for video)
253
+ logger.info("📺 Starting video reception...")
254
+ await consumer.start_receiving()
255
+
256
+ # Step 4: Wait for producer and record
257
+ logger.info("⏳ Waiting for producer to join and start streaming...")
258
+ logger.info(f" Room ID: {room_id}")
259
+ logger.info(" (Start a producer with this room ID to begin recording)")
260
+
261
+ # Wait for recording to complete or timeout
262
+ timeout = 300 # 5 minutes timeout
263
+ start_wait = time.time()
264
+
265
+ while time.time() - start_wait < timeout:
266
+ await asyncio.sleep(1)
267
+
268
+ # Check if recording is complete
269
+ if recording_stats:
270
+ logger.info("🎉 Recording completed successfully!")
271
+ break
272
+
273
+ # Show waiting status every 30 seconds
274
+ elapsed_wait = time.time() - start_wait
275
+ if int(elapsed_wait) % 30 == 0 and elapsed_wait > 0:
276
+ remaining_timeout = timeout - elapsed_wait
277
+ logger.info(
278
+ f"⏳ Still waiting for producer... ({remaining_timeout:.0f}s timeout remaining)"
279
+ )
280
+
281
+ # Final results
282
+ if recording_stats:
283
+ logger.info("📊 Final Recording Results:")
284
+ logger.info(f" Duration: {recording_stats['duration']:.1f}s")
285
+ logger.info(f" Frames: {recording_stats['frame_count']}")
286
+ logger.info(f" FPS: {recording_stats['actual_fps']:.1f}")
287
+ logger.info(f" Video file: {recording_stats['video_path']}")
288
+ logger.info("🎉 SUCCESS: Consumer-first recording completed!")
289
+ else:
290
+ logger.warning("⚠️ No recording was made - producer may not have joined")
291
+
292
+ except Exception as e:
293
+ logger.error(f"❌ Consumer-first recorder failed: {e}")
294
+ import traceback
295
+
296
+ traceback.print_exc()
297
+
298
+ finally:
299
+ # Cleanup
300
+ logger.info("🧹 Cleaning up...")
301
+ try:
302
+ await consumer.stop_receiving()
303
+ await consumer.disconnect()
304
+ logger.info("👋 Consumer disconnected successfully")
305
+ except Exception as e:
306
+ logger.error(f"Error during cleanup: {e}")
307
+
308
+
309
+ if __name__ == "__main__":
310
+ try:
311
+ asyncio.run(main())
312
+ except KeyboardInterrupt:
313
+ logger.info("🛑 Stopped by user")
314
+ logger.info("👋 Goodbye!")
315
+ except Exception as e:
316
+ logger.error(f"💥 Fatal error: {e}")
317
+ import traceback
318
+
319
+ traceback.print_exc()
client/python/examples/context_manager_example.py ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Context Manager Example - LeRobot Arena
4
+
5
+ This example demonstrates:
6
+ - Using clients as context managers for automatic cleanup
7
+ - Exception handling with proper resource cleanup
8
+ - Factory functions for quick client creation
9
+ - Graceful error recovery
10
+ """
11
+
12
+ import asyncio
13
+ import logging
14
+
15
+ from lerobot_arena_client import (
16
+ RoboticsConsumer,
17
+ RoboticsProducer,
18
+ create_consumer_client,
19
+ create_producer_client,
20
+ )
21
+
22
+ # Setup logging
23
+ logging.basicConfig(level=logging.INFO)
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ async def basic_context_manager_example():
28
+ """Basic example using context managers."""
29
+ logger.info("=== Basic Context Manager Example ===")
30
+
31
+ # Using producer as context manager
32
+ async with RoboticsProducer("http://localhost:8000") as producer:
33
+ room_id = await producer.create_room()
34
+ logger.info(f"Created room: {room_id}")
35
+
36
+ await producer.connect(room_id)
37
+ logger.info("Producer connected")
38
+
39
+ # Send some data
40
+ await producer.send_state_sync({"joint1": 45.0, "joint2": -30.0})
41
+ logger.info("State sent")
42
+
43
+ # Exception handling within context
44
+ try:
45
+ # This might fail if room doesn't exist
46
+ await producer.send_joint_update([{"name": "invalid", "value": 999.0}])
47
+ except Exception as e:
48
+ logger.warning(f"Handled exception: {e}")
49
+
50
+ # Producer is automatically disconnected here
51
+ logger.info("Producer automatically disconnected")
52
+
53
+
54
+ async def factory_function_example():
55
+ """Example using factory functions for quick setup."""
56
+ logger.info("\n=== Factory Function Example ===")
57
+
58
+ # Create and auto-connect producer with factory function
59
+ producer = await create_producer_client("http://localhost:8000")
60
+ room_id = producer.room_id
61
+ logger.info(f"Producer auto-connected to room: {room_id}")
62
+
63
+ try:
64
+ # Create and auto-connect consumer
65
+ consumer = await create_consumer_client(room_id, "http://localhost:8000")
66
+ logger.info("Consumer auto-connected")
67
+
68
+ # Set up callback
69
+ received_updates = []
70
+ consumer.on_joint_update(lambda joints: received_updates.append(joints))
71
+
72
+ # Send some updates
73
+ await producer.send_joint_update([
74
+ {"name": "shoulder", "value": 90.0},
75
+ {"name": "elbow", "value": -45.0},
76
+ ])
77
+
78
+ await asyncio.sleep(0.5) # Wait for message propagation
79
+
80
+ logger.info(f"Consumer received {len(received_updates)} updates")
81
+
82
+ finally:
83
+ # Manual cleanup for factory-created clients
84
+ await producer.disconnect()
85
+ await consumer.disconnect()
86
+ logger.info("Manual cleanup completed")
87
+
88
+
89
+ async def exception_handling_example():
90
+ """Example showing exception handling with context managers."""
91
+ logger.info("\n=== Exception Handling Example ===")
92
+
93
+ try:
94
+ async with RoboticsProducer("http://localhost:8000") as producer:
95
+ room_id = await producer.create_room()
96
+ await producer.connect(room_id)
97
+
98
+ # Simulate some work that might fail
99
+ for i in range(5):
100
+ if i == 3:
101
+ # Simulate an error
102
+ raise ValueError("Simulated error during operation")
103
+
104
+ await producer.send_state_sync({f"joint_{i}": float(i * 10)})
105
+ logger.info(f"Sent update {i}")
106
+ await asyncio.sleep(0.1)
107
+
108
+ except ValueError as e:
109
+ logger.error(f"Caught expected error: {e}")
110
+ logger.info("Context manager still ensures cleanup")
111
+
112
+ logger.info("Exception handling example completed")
113
+
114
+
115
+ async def multiple_clients_example():
116
+ """Example with multiple clients using context managers."""
117
+ logger.info("\n=== Multiple Clients Example ===")
118
+
119
+ # Create room first
120
+ async with RoboticsProducer("http://localhost:8000") as setup_producer:
121
+ room_id = await setup_producer.create_room()
122
+ logger.info(f"Setup room: {room_id}")
123
+
124
+ # Now use multiple clients in the same room
125
+ async with RoboticsProducer("http://localhost:8000") as producer:
126
+ await producer.connect(room_id)
127
+
128
+ # Use multiple consumers
129
+ consumers = []
130
+ try:
131
+ # Create multiple consumers with context managers
132
+ for i in range(3):
133
+ consumer = RoboticsConsumer("http://localhost:8000")
134
+ consumers.append(consumer)
135
+ # Note: We're not using context manager here to show manual management
136
+ await consumer.connect(room_id, f"consumer-{i}")
137
+
138
+ logger.info(f"Connected {len(consumers)} consumers")
139
+
140
+ # Send data to all consumers
141
+ await producer.send_state_sync({
142
+ "base": 0.0,
143
+ "shoulder": 45.0,
144
+ "elbow": -30.0,
145
+ "wrist": 15.0,
146
+ })
147
+
148
+ await asyncio.sleep(0.5) # Wait for propagation
149
+ logger.info("Data sent to all consumers")
150
+
151
+ finally:
152
+ # Manual cleanup for consumers
153
+ for i, consumer in enumerate(consumers):
154
+ await consumer.disconnect()
155
+ logger.info(f"Disconnected consumer-{i}")
156
+
157
+ logger.info("Multiple clients example completed")
158
+
159
+
160
+ async def main():
161
+ """Run all context manager examples."""
162
+ logger.info("🤖 LeRobot Arena Context Manager Examples 🤖")
163
+
164
+ try:
165
+ await basic_context_manager_example()
166
+ await factory_function_example()
167
+ await exception_handling_example()
168
+ await multiple_clients_example()
169
+
170
+ logger.info("\n✅ All context manager examples completed successfully!")
171
+
172
+ except Exception as e:
173
+ logger.error(f"❌ Example failed: {e}")
174
+
175
+
176
+ if __name__ == "__main__":
177
+ asyncio.run(main())
client/python/examples/producer_consumer_demo.py ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Producer-Consumer Demo - LeRobot Arena
4
+
5
+ This example demonstrates:
6
+ - Producer and multiple consumers working together
7
+ - Real-time joint updates
8
+ - Emergency stop functionality
9
+ - State synchronization
10
+ - Connection management
11
+ """
12
+
13
+ import asyncio
14
+ import logging
15
+ import random
16
+
17
+ from lerobot_arena_client import RoboticsConsumer, RoboticsProducer
18
+
19
+ # Setup logging
20
+ logging.basicConfig(level=logging.INFO)
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class DemoConsumer:
25
+ """Demo consumer that logs all received messages."""
26
+
27
+ def __init__(self, name: str, room_id: str):
28
+ self.name = name
29
+ self.room_id = room_id
30
+ self.consumer = RoboticsConsumer("http://localhost:8000")
31
+ self.update_count = 0
32
+ self.state_count = 0
33
+
34
+ async def setup(self):
35
+ """Setup consumer with callbacks."""
36
+
37
+ def on_joint_update(joints):
38
+ self.update_count += 1
39
+ logger.info(
40
+ f"[{self.name}] Joint update #{self.update_count}: {len(joints)} joints"
41
+ )
42
+
43
+ def on_state_sync(state):
44
+ self.state_count += 1
45
+ logger.info(
46
+ f"[{self.name}] State sync #{self.state_count}: {len(state)} joints"
47
+ )
48
+
49
+ def on_error(error_msg):
50
+ logger.error(f"[{self.name}] ERROR: {error_msg}")
51
+
52
+ def on_connected():
53
+ logger.info(f"[{self.name}] Connected!")
54
+
55
+ def on_disconnected():
56
+ logger.info(f"[{self.name}] Disconnected!")
57
+
58
+ self.consumer.on_joint_update(on_joint_update)
59
+ self.consumer.on_state_sync(on_state_sync)
60
+ self.consumer.on_error(on_error)
61
+ self.consumer.on_connected(on_connected)
62
+ self.consumer.on_disconnected(on_disconnected)
63
+
64
+ async def connect(self):
65
+ """Connect to room."""
66
+ success = await self.consumer.connect(self.room_id, f"demo-{self.name}")
67
+ if success:
68
+ logger.info(f"[{self.name}] Successfully connected to room {self.room_id}")
69
+ else:
70
+ logger.error(f"[{self.name}] Failed to connect to room {self.room_id}")
71
+ return success
72
+
73
+ async def disconnect(self):
74
+ """Disconnect from room."""
75
+ if self.consumer.is_connected():
76
+ await self.consumer.disconnect()
77
+ logger.info(
78
+ f"[{self.name}] Final stats: {self.update_count} updates, {self.state_count} states"
79
+ )
80
+
81
+
82
+ async def simulate_robot_movement(producer: RoboticsProducer):
83
+ """Simulate realistic robot movement."""
84
+ # Define some realistic joint ranges for a robotic arm
85
+ joints = {
86
+ "base": {"current": 0.0, "target": 0.0, "min": -180, "max": 180},
87
+ "shoulder": {"current": 0.0, "target": 0.0, "min": -90, "max": 90},
88
+ "elbow": {"current": 0.0, "target": 0.0, "min": -135, "max": 135},
89
+ "wrist": {"current": 0.0, "target": 0.0, "min": -180, "max": 180},
90
+ }
91
+
92
+ logger.info("[Producer] Starting robot movement simulation...")
93
+
94
+ for step in range(20): # 20 movement steps
95
+ # Occasionally set new random targets
96
+ if step % 5 == 0:
97
+ for joint_name, joint_data in joints.items():
98
+ joint_data["target"] = random.uniform(
99
+ joint_data["min"], joint_data["max"]
100
+ )
101
+ logger.info(f"[Producer] Step {step + 1}: New targets set")
102
+
103
+ # Move each joint towards its target
104
+ joint_updates = []
105
+ for joint_name, joint_data in joints.items():
106
+ current = joint_data["current"]
107
+ target = joint_data["target"]
108
+
109
+ # Simple movement: move 10% towards target each step
110
+ diff = target - current
111
+ move = diff * 0.1
112
+ new_value = current + move
113
+
114
+ joint_data["current"] = new_value
115
+ joint_updates.append({"name": joint_name, "value": new_value})
116
+
117
+ # Send the joint updates
118
+ await producer.send_joint_update(joint_updates)
119
+
120
+ # Add some delay for realistic movement
121
+ await asyncio.sleep(0.5)
122
+
123
+ logger.info("[Producer] Movement simulation completed")
124
+
125
+
126
+ async def main():
127
+ """Main demo function."""
128
+ logger.info("=== LeRobot Arena Producer-Consumer Demo ===")
129
+
130
+ # Create producer
131
+ producer = RoboticsProducer("http://localhost:8000")
132
+
133
+ # Setup producer callbacks
134
+ def on_producer_error(error_msg):
135
+ logger.error(f"[Producer] ERROR: {error_msg}")
136
+
137
+ def on_producer_connected():
138
+ logger.info("[Producer] Connected!")
139
+
140
+ def on_producer_disconnected():
141
+ logger.info("[Producer] Disconnected!")
142
+
143
+ producer.on_error(on_producer_error)
144
+ producer.on_connected(on_producer_connected)
145
+ producer.on_disconnected(on_producer_disconnected)
146
+
147
+ try:
148
+ # Create room and connect producer
149
+ room_id = await producer.create_room()
150
+ logger.info(f"Created room: {room_id}")
151
+
152
+ success = await producer.connect(room_id, "robot-controller")
153
+ if not success:
154
+ logger.error("Failed to connect producer!")
155
+ return
156
+
157
+ # Create multiple consumers
158
+ consumers = []
159
+ consumer_names = ["visualizer", "logger", "safety-monitor"]
160
+
161
+ for name in consumer_names:
162
+ consumer = DemoConsumer(name, room_id)
163
+ await consumer.setup()
164
+ consumers.append(consumer)
165
+
166
+ # Connect all consumers
167
+ logger.info("Connecting consumers...")
168
+ for consumer in consumers:
169
+ await consumer.connect()
170
+ await asyncio.sleep(0.1) # Small delay between connections
171
+
172
+ # Send initial state
173
+ logger.info("[Producer] Sending initial state...")
174
+ initial_state = {"base": 0.0, "shoulder": 0.0, "elbow": 0.0, "wrist": 0.0}
175
+ await producer.send_state_sync(initial_state)
176
+ await asyncio.sleep(1)
177
+
178
+ # Start robot movement simulation
179
+ movement_task = asyncio.create_task(simulate_robot_movement(producer))
180
+
181
+ # Let it run for a bit
182
+ await asyncio.sleep(5)
183
+
184
+ # Demonstrate emergency stop
185
+ logger.info("🚨 [Producer] Sending emergency stop!")
186
+ await producer.send_emergency_stop(
187
+ "Demo emergency stop - testing safety systems"
188
+ )
189
+
190
+ # Wait for movement to complete
191
+ await movement_task
192
+
193
+ # Final state check
194
+ logger.info("=== Final Demo Summary ===")
195
+ for consumer in consumers:
196
+ logger.info(
197
+ f"[{consumer.name}] Received {consumer.update_count} updates, {consumer.state_count} states"
198
+ )
199
+
200
+ logger.info("Demo completed successfully!")
201
+
202
+ except Exception as e:
203
+ logger.error(f"Demo error: {e}")
204
+ finally:
205
+ # Cleanup
206
+ logger.info("Cleaning up...")
207
+
208
+ # Disconnect all consumers
209
+ for consumer in consumers:
210
+ await consumer.disconnect()
211
+
212
+ # Disconnect producer
213
+ if producer.is_connected():
214
+ await producer.disconnect()
215
+
216
+ logger.info("Demo cleanup completed")
217
+
218
+
219
+ if __name__ == "__main__":
220
+ asyncio.run(main())
client/python/examples/room_management.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Room Management Example - LeRobot Arena
4
+
5
+ This example demonstrates:
6
+ - Listing rooms
7
+ - Creating rooms (with and without custom IDs)
8
+ - Getting room information
9
+ - Getting room state
10
+ - Deleting rooms
11
+ """
12
+
13
+ import asyncio
14
+ import logging
15
+
16
+ from lerobot_arena_client import RoboticsClientCore
17
+
18
+ # Setup logging
19
+ logging.basicConfig(level=logging.INFO)
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ async def main():
24
+ """Room management example."""
25
+ # Create a basic client for API operations
26
+ client = RoboticsClientCore("http://localhost:8000")
27
+
28
+ try:
29
+ # List existing rooms
30
+ logger.info("=== Listing existing rooms ===")
31
+ rooms = await client.list_rooms()
32
+ logger.info(f"Found {len(rooms)} rooms:")
33
+ for room in rooms:
34
+ logger.info(
35
+ f" - {room['id']}: {room['participants']['total']} participants"
36
+ )
37
+
38
+ # Create a room with auto-generated ID
39
+ logger.info("\n=== Creating room with auto-generated ID ===")
40
+ room_id_1 = await client.create_room()
41
+ logger.info(f"Created room: {room_id_1}")
42
+
43
+ # Create a room with custom ID
44
+ logger.info("\n=== Creating room with custom ID ===")
45
+ custom_room_id = "my-custom-room-123"
46
+ room_id_2 = await client.create_room(custom_room_id)
47
+ logger.info(f"Created custom room: {room_id_2}")
48
+
49
+ # Get room info
50
+ logger.info(f"\n=== Getting info for room {room_id_1} ===")
51
+ room_info = await client.get_room_info(room_id_1)
52
+ logger.info(f"Room info: {room_info}")
53
+
54
+ # Get room state
55
+ logger.info(f"\n=== Getting state for room {room_id_1} ===")
56
+ room_state = await client.get_room_state(room_id_1)
57
+ logger.info(f"Room state: {room_state}")
58
+
59
+ # List rooms again to see our new ones
60
+ logger.info("\n=== Listing rooms after creation ===")
61
+ rooms = await client.list_rooms()
62
+ logger.info(f"Now have {len(rooms)} rooms:")
63
+ for room in rooms:
64
+ logger.info(
65
+ f" - {room['id']}: {room['participants']['total']} participants"
66
+ )
67
+
68
+ # Clean up - delete the rooms we created
69
+ logger.info("\n=== Cleaning up ===")
70
+
71
+ success_1 = await client.delete_room(room_id_1)
72
+ logger.info(f"Deleted room {room_id_1}: {success_1}")
73
+
74
+ success_2 = await client.delete_room(room_id_2)
75
+ logger.info(f"Deleted room {room_id_2}: {success_2}")
76
+
77
+ # Try to delete non-existent room
78
+ success_3 = await client.delete_room("non-existent-room")
79
+ logger.info(f"Tried to delete non-existent room: {success_3}")
80
+
81
+ # List final rooms
82
+ logger.info("\n=== Final room list ===")
83
+ rooms = await client.list_rooms()
84
+ logger.info(f"Final count: {len(rooms)} rooms")
85
+
86
+ logger.info("\nRoom management example completed!")
87
+
88
+ except Exception as e:
89
+ logger.error(f"Error: {e}")
90
+
91
+
92
+ if __name__ == "__main__":
93
+ asyncio.run(main())
client/python/examples/test_consumer_fix.py ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Test Consumer Fix
4
+
5
+ This script tests the fixed Python video consumer to ensure it can properly
6
+ receive and decode video frames from the server.
7
+ """
8
+
9
+ import asyncio
10
+ import logging
11
+ import time
12
+ from pathlib import Path
13
+
14
+ import cv2
15
+ import numpy as np
16
+ from lerobot_arena_client.video import VideoConsumer
17
+
18
+ # Setup logging
19
+ logging.basicConfig(
20
+ level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
21
+ )
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class FrameProcessor:
26
+ """Processes received video frames and saves them for verification"""
27
+
28
+ def __init__(self, output_dir: str = "./test_frames"):
29
+ self.output_dir = Path(output_dir)
30
+ self.output_dir.mkdir(exist_ok=True)
31
+ self.frame_count = 0
32
+ self.total_bytes = 0
33
+ self.start_time = time.time()
34
+ self.last_frame_time = time.time()
35
+
36
+ def process_frame(self, frame_data):
37
+ """Process received frame data"""
38
+ try:
39
+ self.frame_count += 1
40
+ current_time = time.time()
41
+
42
+ # Extract metadata
43
+ metadata = frame_data.metadata
44
+ width = metadata.get("width", 0)
45
+ height = metadata.get("height", 0)
46
+ format_type = metadata.get("format", "unknown")
47
+
48
+ # Convert bytes back to numpy array
49
+ frame_bytes = frame_data.data
50
+ self.total_bytes += len(frame_bytes)
51
+
52
+ # Reconstruct numpy array from bytes
53
+ img = np.frombuffer(frame_bytes, dtype=np.uint8).reshape((height, width, 3))
54
+
55
+ # Save every 10th frame for verification
56
+ if self.frame_count % 10 == 0:
57
+ # Convert RGB to BGR for OpenCV saving
58
+ img_bgr = cv2.cvtColor(img, cv2.COLOR_RGB2BGR)
59
+ frame_path = self.output_dir / f"frame_{self.frame_count:06d}.jpg"
60
+ cv2.imwrite(str(frame_path), img_bgr)
61
+ logger.info(f"💾 Saved frame {self.frame_count} to {frame_path}")
62
+
63
+ # Calculate FPS
64
+ fps = (
65
+ 1.0 / (current_time - self.last_frame_time)
66
+ if self.frame_count > 1
67
+ else 0
68
+ )
69
+ self.last_frame_time = current_time
70
+
71
+ # Log progress every 30 frames
72
+ if self.frame_count % 30 == 0:
73
+ elapsed = current_time - self.start_time
74
+ avg_fps = self.frame_count / elapsed if elapsed > 0 else 0
75
+ mb_received = self.total_bytes / (1024 * 1024)
76
+
77
+ logger.info("📊 Frame Stats:")
78
+ logger.info(f" Frames: {self.frame_count}")
79
+ logger.info(f" Resolution: {width}x{height}")
80
+ logger.info(f" Format: {format_type}")
81
+ logger.info(f" Current FPS: {fps:.1f}")
82
+ logger.info(f" Average FPS: {avg_fps:.1f}")
83
+ logger.info(f" Data received: {mb_received:.2f} MB")
84
+
85
+ except Exception as e:
86
+ logger.error(f"❌ Error processing frame {self.frame_count}: {e}")
87
+
88
+
89
+ async def test_consumer_fix():
90
+ """Test the fixed consumer implementation"""
91
+ # Connect to the "webcam" room mentioned in the conversation
92
+ room_id = "webcam"
93
+ base_url = "http://localhost:8000"
94
+
95
+ logger.info("🎬 Testing Fixed Video Consumer")
96
+ logger.info("=" * 50)
97
+ logger.info(f"Room ID: {room_id}")
98
+ logger.info(f"Server: {base_url}")
99
+
100
+ # Create frame processor
101
+ processor = FrameProcessor()
102
+
103
+ # Create consumer
104
+ consumer = VideoConsumer(base_url)
105
+
106
+ # Set up frame callback
107
+ consumer.on_frame_update(processor.process_frame)
108
+
109
+ # Track connection states
110
+ connection_established = False
111
+ frames_received = False
112
+
113
+ def on_track_received(track):
114
+ nonlocal connection_established
115
+ connection_established = True
116
+ logger.info(f"✅ Video track received: {track.kind}")
117
+
118
+ try:
119
+ logger.info("🔌 Connecting to room...")
120
+ connected = await consumer.connect(room_id)
121
+
122
+ if not connected:
123
+ logger.error("❌ Failed to connect to room")
124
+ return False
125
+
126
+ logger.info("✅ Connected to room successfully")
127
+
128
+ # Start receiving
129
+ logger.info("📺 Starting video reception...")
130
+ await consumer.start_receiving()
131
+
132
+ # Wait for frames with timeout
133
+ test_duration = 30 # 30 seconds
134
+ logger.info(f"⏱️ Testing for {test_duration} seconds...")
135
+
136
+ start_time = time.time()
137
+ while time.time() - start_time < test_duration:
138
+ await asyncio.sleep(1)
139
+
140
+ # Check if we're receiving frames
141
+ if processor.frame_count > 0 and not frames_received:
142
+ frames_received = True
143
+ logger.info("🎉 First frame received successfully!")
144
+
145
+ # Show periodic status
146
+ if int(time.time() - start_time) % 5 == 0:
147
+ elapsed = time.time() - start_time
148
+ logger.info(
149
+ f"⏱️ Test progress: {elapsed:.0f}s - Frames: {processor.frame_count}"
150
+ )
151
+
152
+ # Final results
153
+ logger.info("📊 Test Results:")
154
+ logger.info(f" Connection established: {connection_established}")
155
+ logger.info(f" Frames received: {frames_received}")
156
+ logger.info(f" Total frames: {processor.frame_count}")
157
+
158
+ if processor.frame_count > 0:
159
+ elapsed = time.time() - processor.start_time
160
+ avg_fps = processor.frame_count / elapsed
161
+ mb_total = processor.total_bytes / (1024 * 1024)
162
+
163
+ logger.info(f" Average FPS: {avg_fps:.1f}")
164
+ logger.info(f" Total data: {mb_total:.2f} MB")
165
+ logger.info(
166
+ f" Saved frames: {len(list(processor.output_dir.glob('*.jpg')))}"
167
+ )
168
+
169
+ # Verify saved frames
170
+ saved_frames = list(processor.output_dir.glob("*.jpg"))
171
+ if saved_frames:
172
+ logger.info(f"✅ SUCCESS: Frames saved to {processor.output_dir}")
173
+ logger.info(f" Example frame: {saved_frames[0]}")
174
+
175
+ return True
176
+ logger.error("❌ FAILED: No frames received")
177
+ return False
178
+
179
+ except Exception as e:
180
+ logger.error(f"❌ Test failed with error: {e}")
181
+ import traceback
182
+
183
+ traceback.print_exc()
184
+ return False
185
+
186
+ finally:
187
+ # Cleanup
188
+ logger.info("🧹 Cleaning up...")
189
+ await consumer.stop_receiving()
190
+ logger.info("👋 Test completed")
191
+
192
+
193
+ async def main():
194
+ """Main test function"""
195
+ try:
196
+ success = await test_consumer_fix()
197
+ if success:
198
+ logger.info("🎉 Consumer fix test PASSED!")
199
+ return 0
200
+ logger.error("💥 Consumer fix test FAILED!")
201
+ return 1
202
+ except KeyboardInterrupt:
203
+ logger.info("🛑 Test interrupted by user")
204
+ return 1
205
+ except Exception as e:
206
+ logger.error(f"💥 Unexpected error: {e}")
207
+ return 1
208
+
209
+
210
+ if __name__ == "__main__":
211
+ import sys
212
+
213
+ exit_code = asyncio.run(main())
214
+ sys.exit(exit_code)
client/python/examples/video_consumer_example.py ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Video Consumer Example - Fixed Version
4
+
5
+ This example demonstrates how to connect as a video consumer and receive
6
+ video frames from a producer in the LeRobot Arena.
7
+ """
8
+
9
+ import asyncio
10
+ import logging
11
+ import time
12
+ from pathlib import Path
13
+
14
+ import cv2
15
+ import numpy as np
16
+ from lerobot_arena_client.video import VideoConsumer
17
+
18
+ # Setup logging
19
+ logging.basicConfig(
20
+ level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
21
+ )
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class VideoFrameHandler:
26
+ """Handles received video frames with optional saving and display"""
27
+
28
+ def __init__(
29
+ self, save_frames: bool = False, output_dir: str = "./received_frames"
30
+ ):
31
+ self.save_frames = save_frames
32
+ self.output_dir = Path(output_dir) if save_frames else None
33
+ if self.output_dir:
34
+ self.output_dir.mkdir(exist_ok=True)
35
+
36
+ self.frame_count = 0
37
+ self.total_bytes = 0
38
+ self.start_time = time.time()
39
+ self.last_log_time = time.time()
40
+
41
+ def handle_frame(self, frame_data):
42
+ """Process received frame data"""
43
+ try:
44
+ self.frame_count += 1
45
+ current_time = time.time()
46
+
47
+ # Extract frame information
48
+ metadata = frame_data.metadata
49
+ width = metadata.get("width", 0)
50
+ height = metadata.get("height", 0)
51
+ format_type = metadata.get("format", "unknown")
52
+
53
+ # Convert bytes to numpy array
54
+ frame_bytes = frame_data.data
55
+ self.total_bytes += len(frame_bytes)
56
+
57
+ # Reconstruct image from bytes (server sends RGB format)
58
+ img = np.frombuffer(frame_bytes, dtype=np.uint8).reshape((height, width, 3))
59
+
60
+ # Save frames if requested
61
+ if self.save_frames and self.frame_count % 30 == 0: # Save every 30th frame
62
+ # Convert RGB to BGR for OpenCV
63
+ img_bgr = cv2.cvtColor(img, cv2.COLOR_RGB2BGR)
64
+ frame_path = self.output_dir / f"frame_{self.frame_count:06d}.jpg"
65
+ cv2.imwrite(str(frame_path), img_bgr)
66
+ logger.info(f"💾 Saved frame {self.frame_count} to {frame_path}")
67
+
68
+ # Log statistics periodically
69
+ if current_time - self.last_log_time >= 5.0: # Every 5 seconds
70
+ elapsed = current_time - self.start_time
71
+ fps = self.frame_count / elapsed if elapsed > 0 else 0
72
+ mb_received = self.total_bytes / (1024 * 1024)
73
+
74
+ logger.info("📊 Video Stats:")
75
+ logger.info(f" Frames received: {self.frame_count}")
76
+ logger.info(f" Resolution: {width}x{height}")
77
+ logger.info(f" Format: {format_type}")
78
+ logger.info(f" Average FPS: {fps:.1f}")
79
+ logger.info(f" Data received: {mb_received:.2f} MB")
80
+
81
+ self.last_log_time = current_time
82
+
83
+ except Exception as e:
84
+ logger.error(f"❌ Error handling frame {self.frame_count}: {e}")
85
+
86
+
87
+ async def main():
88
+ """Main consumer example"""
89
+ # Configuration
90
+ room_id = "webcam" # Use the test webcam room
91
+ base_url = "http://localhost:8000"
92
+ duration = 60 # Run for 60 seconds
93
+ save_frames = True # Save some frames as proof
94
+
95
+ logger.info("🎬 Video Consumer Example - Fixed Version")
96
+ logger.info("=" * 50)
97
+ logger.info(f"Room ID: {room_id}")
98
+ logger.info(f"Server: {base_url}")
99
+ logger.info(f"Duration: {duration} seconds")
100
+ logger.info(f"Save frames: {save_frames}")
101
+
102
+ # Create frame handler
103
+ frame_handler = VideoFrameHandler(save_frames=save_frames)
104
+
105
+ # Create consumer
106
+ consumer = VideoConsumer(base_url)
107
+
108
+ # Set up event handlers
109
+ consumer.on_frame_update(frame_handler.handle_frame)
110
+
111
+ # Track connection progress
112
+ connection_events = []
113
+
114
+ try:
115
+ logger.info("🔌 Connecting to room...")
116
+ connected = await consumer.connect(room_id)
117
+
118
+ if not connected:
119
+ logger.error("❌ Failed to connect to room")
120
+ return
121
+
122
+ logger.info("✅ Connected to room successfully")
123
+ connection_events.append("connected")
124
+
125
+ # Start receiving video
126
+ logger.info("📺 Starting video reception...")
127
+ await consumer.start_receiving()
128
+ connection_events.append("receiving_started")
129
+
130
+ # Run for specified duration
131
+ logger.info(f"⏱️ Running for {duration} seconds...")
132
+ logger.info("📺 Waiting for video frames... (Press Ctrl+C to stop early)")
133
+
134
+ start_time = time.time()
135
+ try:
136
+ while time.time() - start_time < duration:
137
+ await asyncio.sleep(1)
138
+
139
+ # Show progress
140
+ elapsed = time.time() - start_time
141
+ if int(elapsed) % 10 == 0 and elapsed > 0: # Every 10 seconds
142
+ logger.info(
143
+ f"⏱️ Progress: {elapsed:.0f}s - Frames: {frame_handler.frame_count}"
144
+ )
145
+
146
+ except KeyboardInterrupt:
147
+ logger.info("🛑 Stopped by user")
148
+
149
+ # Final statistics
150
+ elapsed = time.time() - start_time
151
+ logger.info("📊 Final Results:")
152
+ logger.info(f" Test duration: {elapsed:.1f} seconds")
153
+ logger.info(f" Total frames: {frame_handler.frame_count}")
154
+ logger.info(f" Connection events: {connection_events}")
155
+
156
+ if frame_handler.frame_count > 0:
157
+ avg_fps = frame_handler.frame_count / elapsed
158
+ mb_total = frame_handler.total_bytes / (1024 * 1024)
159
+
160
+ logger.info(f" Average FPS: {avg_fps:.1f}")
161
+ logger.info(f" Total data: {mb_total:.2f} MB")
162
+
163
+ if save_frames and frame_handler.output_dir:
164
+ saved_files = list(frame_handler.output_dir.glob("*.jpg"))
165
+ logger.info(f" Saved frames: {len(saved_files)}")
166
+ if saved_files:
167
+ logger.info(f" Output directory: {frame_handler.output_dir}")
168
+
169
+ logger.info("🎉 SUCCESS: Video consumer is working correctly!")
170
+ else:
171
+ logger.warning("⚠️ No frames received - check if producer is active")
172
+
173
+ except Exception as e:
174
+ logger.error(f"❌ Consumer example failed: {e}")
175
+ import traceback
176
+
177
+ traceback.print_exc()
178
+
179
+ finally:
180
+ # Cleanup
181
+ logger.info("🧹 Cleaning up...")
182
+ try:
183
+ await consumer.stop_receiving()
184
+ logger.info("👋 Consumer stopped successfully")
185
+ except Exception as e:
186
+ logger.error(f"Error during cleanup: {e}")
187
+
188
+
189
+ if __name__ == "__main__":
190
+ try:
191
+ asyncio.run(main())
192
+ except KeyboardInterrupt:
193
+ logger.info("👋 Goodbye!")
194
+ except Exception as e:
195
+ logger.error(f"💥 Fatal error: {e}")
196
+ import traceback
197
+
198
+ traceback.print_exc()
client/python/examples/video_producer_example.py ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Basic Video Producer Example
4
+
5
+ Demonstrates how to use the LeRobot Arena Python video client for streaming.
6
+ This example creates animated video content and streams it to the arena server.
7
+ """
8
+
9
+ import asyncio
10
+ import logging
11
+ import time
12
+
13
+ import numpy as np
14
+
15
+ # Import the video client
16
+ from lerobot_arena_client.video import (
17
+ VideoProducer,
18
+ create_producer_client,
19
+ )
20
+
21
+
22
+ async def animated_frame_source() -> np.ndarray | None:
23
+ """Create animated frames with colorful patterns"""
24
+ # Create a colorful animated frame
25
+ height, width = 480, 640
26
+ frame_count = int(time.time() * 30) % 1000 # 30 fps simulation
27
+
28
+ # Generate animated RGB channels using vectorized operations
29
+ time_factor = frame_count * 0.1
30
+
31
+ # Create colorful animated patterns
32
+ y_coords, x_coords = np.meshgrid(np.arange(width), np.arange(height), indexing="xy")
33
+
34
+ r = (128 + 127 * np.sin(time_factor + x_coords * 0.01)).astype(np.uint8)
35
+ g = (128 + 127 * np.sin(time_factor + y_coords * 0.01)).astype(np.uint8)
36
+ b = (128 + 127 * np.sin(time_factor) * np.ones((height, width))).astype(np.uint8)
37
+
38
+ # Stack into RGB frame
39
+ frame = np.stack([r, g, b], axis=2)
40
+
41
+ # Add a moving circle for visual feedback
42
+ center_x = int(320 + 200 * np.sin(frame_count * 0.05))
43
+ center_y = int(240 + 100 * np.cos(frame_count * 0.05))
44
+
45
+ # Create circle mask
46
+ circle_mask = (x_coords - center_x) ** 2 + (y_coords - center_y) ** 2 < 50**2
47
+ frame[circle_mask] = [255, 255, 0] # Yellow circle
48
+
49
+ # Add frame counter text overlay
50
+ import cv2
51
+
52
+ cv2.putText(
53
+ frame,
54
+ f"Frame {frame_count}",
55
+ (20, 50),
56
+ cv2.FONT_HERSHEY_SIMPLEX,
57
+ 1.5,
58
+ (0, 0, 0),
59
+ 3,
60
+ ) # Black outline
61
+ cv2.putText(
62
+ frame,
63
+ f"Frame {frame_count}",
64
+ (20, 50),
65
+ cv2.FONT_HERSHEY_SIMPLEX,
66
+ 1.5,
67
+ (255, 255, 255),
68
+ 2,
69
+ ) # White text
70
+
71
+ return frame
72
+
73
+
74
+ async def main():
75
+ """Main function demonstrating video producer functionality"""
76
+ # Configure logging
77
+ logging.basicConfig(
78
+ level=logging.INFO,
79
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
80
+ )
81
+ logger = logging.getLogger(__name__)
82
+
83
+ logger.info("🚀 Starting LeRobot Arena Video Producer Example")
84
+
85
+ try:
86
+ # Create video producer with configuration
87
+ producer = VideoProducer(base_url="http://localhost:8000")
88
+
89
+ # Set up event handlers
90
+ producer.on_connected(lambda: logger.info("✅ Connected to video server"))
91
+ producer.on_disconnected(
92
+ lambda: logger.info("👋 Disconnected from video server")
93
+ )
94
+ producer.on_error(lambda error: logger.error(f"❌ Error: {error}"))
95
+
96
+ producer.on_status_update(
97
+ lambda status, data: logger.info(f"📊 Status: {status}")
98
+ )
99
+ producer.on_stream_stats(
100
+ lambda stats: logger.debug(f"📈 Stats: {stats.average_fps:.1f}fps")
101
+ )
102
+
103
+ # Create a room and connect
104
+ room_id = await producer.create_room()
105
+ logger.info(f"🏠 Created room: {room_id}")
106
+
107
+ connected = await producer.connect(room_id)
108
+ if not connected:
109
+ logger.error("❌ Failed to connect to room")
110
+ return
111
+
112
+ logger.info(f"✅ Connected as producer to room: {room_id}")
113
+
114
+ # Start custom video stream with animated content
115
+ logger.info("🎬 Starting animated video stream...")
116
+ await producer.start_custom_stream(animated_frame_source)
117
+
118
+ logger.info("📺 Video streaming started!")
119
+ logger.info(f"🔗 Consumers can connect to room: {room_id}")
120
+ logger.info(
121
+ f"📱 Use JS consumer: http://localhost:5173/consumer?room={room_id}"
122
+ )
123
+
124
+ # Stream for demo duration
125
+ duration = 30 # Stream for 30 seconds
126
+ logger.info(f"⏱️ Streaming for {duration} seconds...")
127
+
128
+ for i in range(duration):
129
+ await asyncio.sleep(1)
130
+ if i % 5 == 0:
131
+ logger.info(f"📡 Streaming... {duration - i} seconds remaining")
132
+
133
+ logger.info("🛑 Stopping video stream...")
134
+ await producer.stop_streaming()
135
+
136
+ except Exception as e:
137
+ logger.error(f"❌ Unexpected error: {e}")
138
+ import traceback
139
+
140
+ traceback.print_exc()
141
+ finally:
142
+ # Clean up
143
+ logger.info("🧹 Cleaning up...")
144
+ if "producer" in locals():
145
+ await producer.disconnect()
146
+ logger.info("✅ Video producer example completed")
147
+
148
+
149
+ async def camera_example():
150
+ """Example using actual camera (if available)"""
151
+ logging.basicConfig(level=logging.INFO)
152
+ logger = logging.getLogger(__name__)
153
+
154
+ logger.info("📷 Starting Camera Video Producer Example")
155
+
156
+ try:
157
+ # Create producer using factory function
158
+ producer = await create_producer_client(base_url="http://localhost:8000")
159
+
160
+ room_id = producer.current_room_id
161
+ logger.info(f"🏠 Connected to room: {room_id}")
162
+
163
+ # Get available cameras
164
+ cameras = await producer.get_camera_devices()
165
+ if cameras:
166
+ logger.info("📹 Available cameras:")
167
+ for camera in cameras:
168
+ logger.info(
169
+ f" Device {camera['device_id']}: {camera['name']} "
170
+ f"({camera['resolution']['width']}x{camera['resolution']['height']})"
171
+ )
172
+
173
+ # Start camera stream
174
+ logger.info("📷 Starting camera stream...")
175
+ await producer.start_camera(device_id=0)
176
+
177
+ logger.info("📺 Camera streaming started!")
178
+ logger.info(f"🔗 Consumers can connect to room: {room_id}")
179
+
180
+ # Stream for demo duration
181
+ await asyncio.sleep(30)
182
+
183
+ else:
184
+ logger.warning("⚠️ No cameras found")
185
+
186
+ except Exception as e:
187
+ logger.error(f"❌ Camera error: {e}")
188
+ logger.info("💡 Make sure your camera is available and not used by other apps")
189
+ finally:
190
+ if "producer" in locals():
191
+ await producer.disconnect()
192
+
193
+
194
+ async def screen_share_example():
195
+ """Example using screen sharing (animated pattern for demo)"""
196
+ logging.basicConfig(level=logging.INFO)
197
+ logger = logging.getLogger(__name__)
198
+
199
+ logger.info("🖥️ Starting Screen Share Example")
200
+
201
+ try:
202
+ producer = await create_producer_client()
203
+ room_id = producer.current_room_id
204
+
205
+ logger.info("🖥️ Starting screen share...")
206
+ await producer.start_screen_share()
207
+
208
+ logger.info(f"📺 Screen sharing started! Room: {room_id}")
209
+
210
+ # Share for demo duration
211
+ await asyncio.sleep(20)
212
+
213
+ except Exception as e:
214
+ logger.error(f"❌ Screen share error: {e}")
215
+ finally:
216
+ if "producer" in locals():
217
+ await producer.disconnect()
218
+
219
+
220
+ if __name__ == "__main__":
221
+ import sys
222
+
223
+ if len(sys.argv) > 1:
224
+ mode = sys.argv[1]
225
+
226
+ if mode == "camera":
227
+ asyncio.run(camera_example())
228
+ elif mode == "screen":
229
+ asyncio.run(screen_share_example())
230
+ elif mode == "animated":
231
+ asyncio.run(main())
232
+ else:
233
+ print("Usage:")
234
+ print(" python video_producer_example.py animated # Animated content")
235
+ print(" python video_producer_example.py camera # Camera stream")
236
+ print(" python video_producer_example.py screen # Screen share")
237
+ else:
238
+ # Default: run animated example
239
+ asyncio.run(main())
client/python/pyproject.toml ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "lerobot_arena_client"
3
+ version = "0.1.0"
4
+ description = "Python client for LeRobot Arena robotics and video streaming API"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = [
8
+ "aiohttp>=3.9.0",
9
+ "websockets>=15.0.1",
10
+ # Video client dependencies
11
+ "aiortc>=1.9.0",
12
+ "opencv-python>=4.10.0",
13
+ "numpy>=1.26.0",
14
+ "av>=13.0.0",
15
+ ]
16
+
17
+ [dependency-groups]
18
+ dev = [
19
+ "pytest>=8.4.0",
20
+ "pytest-asyncio>=1.0.0",
21
+ ]
22
+
23
+ [build-system]
24
+ requires = ["hatchling"]
25
+ build-backend = "hatchling.build"
client/python/src/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (526 Bytes). View file
 
client/python/src/lerobot_arena_client/__init__.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Import video module
2
+ from lerobot_arena_client import video
3
+ from lerobot_arena_client.client import (
4
+ RoboticsClientCore,
5
+ RoboticsConsumer,
6
+ RoboticsProducer,
7
+ create_client,
8
+ create_consumer_client,
9
+ create_producer_client,
10
+ )
11
+
12
+ __all__ = [
13
+ # Robotics exports
14
+ "RoboticsClientCore",
15
+ "RoboticsConsumer",
16
+ "RoboticsProducer",
17
+ "create_client",
18
+ "create_consumer_client",
19
+ "create_producer_client",
20
+ # Video module
21
+ "video",
22
+ ]
client/python/src/lerobot_arena_client/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (554 Bytes). View file
 
client/python/src/lerobot_arena_client/__pycache__/client.cpython-312.pyc ADDED
Binary file (28.2 kB). View file
 
client/python/src/lerobot_arena_client/__pycache__/client.cpython-313.pyc ADDED
Binary file (28.6 kB). View file
 
client/python/src/lerobot_arena_client/client.py ADDED
@@ -0,0 +1,513 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ from collections.abc import Callable
5
+ from urllib.parse import urlparse
6
+
7
+ import aiohttp
8
+ import websockets
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class RoboticsClientCore:
14
+ """Base client for LeRobot Arena robotics API"""
15
+
16
+ def __init__(self, base_url: str = "http://localhost:8000"):
17
+ self.base_url = base_url.rstrip("/")
18
+ self.api_base = f"{self.base_url}/robotics"
19
+
20
+ # WebSocket connection
21
+ self.websocket: websockets.WebSocketServerProtocol | None = None
22
+ self.workspace_id: str | None = None
23
+ self.room_id: str | None = None
24
+ self.role: str | None = None
25
+ self.participant_id: str | None = None
26
+ self.connected = False
27
+
28
+ # Background task for message handling
29
+ self._message_task: asyncio.Task | None = None
30
+
31
+ # ============= REST API METHODS =============
32
+
33
+ async def list_rooms(self, workspace_id: str) -> list[dict]:
34
+ """List all available rooms in a workspace"""
35
+ async with aiohttp.ClientSession() as session:
36
+ async with session.get(
37
+ f"{self.api_base}/workspaces/{workspace_id}/rooms"
38
+ ) as response:
39
+ response.raise_for_status()
40
+ result = await response.json()
41
+ # Extract the rooms list from the response
42
+ return result.get("rooms", [])
43
+
44
+ async def create_room(
45
+ self, workspace_id: str | None = None, room_id: str | None = None
46
+ ) -> tuple[str, str]:
47
+ """Create a new room and return (workspace_id, room_id)"""
48
+ # Generate workspace ID if not provided
49
+ final_workspace_id = workspace_id or self._generate_workspace_id()
50
+
51
+ payload = {}
52
+ if room_id:
53
+ payload["room_id"] = room_id
54
+
55
+ async with aiohttp.ClientSession() as session:
56
+ async with session.post(
57
+ f"{self.api_base}/workspaces/{final_workspace_id}/rooms", json=payload
58
+ ) as response:
59
+ response.raise_for_status()
60
+ result = await response.json()
61
+ return result["workspace_id"], result["room_id"]
62
+
63
+ async def delete_room(self, workspace_id: str, room_id: str) -> bool:
64
+ """Delete a room"""
65
+ async with aiohttp.ClientSession() as session:
66
+ async with session.delete(
67
+ f"{self.api_base}/workspaces/{workspace_id}/rooms/{room_id}"
68
+ ) as response:
69
+ if response.status == 404:
70
+ return False
71
+ response.raise_for_status()
72
+ result = await response.json()
73
+ return result["success"]
74
+
75
+ async def get_room_state(self, workspace_id: str, room_id: str) -> dict:
76
+ """Get current room state"""
77
+ async with aiohttp.ClientSession() as session:
78
+ async with session.get(
79
+ f"{self.api_base}/workspaces/{workspace_id}/rooms/{room_id}/state"
80
+ ) as response:
81
+ response.raise_for_status()
82
+ result = await response.json()
83
+ # Extract the state from the response
84
+ return result.get("state", {})
85
+
86
+ async def get_room_info(self, workspace_id: str, room_id: str) -> dict:
87
+ """Get basic room information"""
88
+ async with aiohttp.ClientSession() as session:
89
+ async with session.get(
90
+ f"{self.api_base}/workspaces/{workspace_id}/rooms/{room_id}"
91
+ ) as response:
92
+ response.raise_for_status()
93
+ result = await response.json()
94
+ # Extract the room data from the response
95
+ return result.get("room", {})
96
+
97
+ # ============= WEBSOCKET CONNECTION =============
98
+
99
+ async def connect_to_room(
100
+ self,
101
+ workspace_id: str,
102
+ room_id: str,
103
+ role: str,
104
+ participant_id: str | None = None,
105
+ ) -> bool:
106
+ """Connect to a room as producer or consumer"""
107
+ if self.connected:
108
+ await self.disconnect()
109
+
110
+ self.workspace_id = workspace_id
111
+ self.room_id = room_id
112
+ self.role = role
113
+ self.participant_id = participant_id or f"{role}_{id(self)}"
114
+
115
+ # Convert HTTP URL to WebSocket URL
116
+ parsed = urlparse(self.base_url)
117
+ ws_scheme = "wss" if parsed.scheme == "https" else "ws"
118
+ ws_url = f"{ws_scheme}://{parsed.netloc}/robotics/workspaces/{workspace_id}/rooms/{room_id}/ws"
119
+
120
+ initial_state_sync = None
121
+
122
+ try:
123
+ self.websocket = await websockets.connect(ws_url)
124
+
125
+ # Send join message
126
+ join_message = {"participant_id": self.participant_id, "role": role}
127
+ await self.websocket.send(json.dumps(join_message))
128
+
129
+ # Wait for server response to join message
130
+ try:
131
+ response_text = await asyncio.wait_for(
132
+ self.websocket.recv(), timeout=5.0
133
+ )
134
+ response = json.loads(response_text)
135
+
136
+ if response.get("type") == "error":
137
+ logger.error(
138
+ f"Server rejected connection: {response.get('message')}"
139
+ )
140
+ await self.websocket.close()
141
+ return False
142
+ if response.get("type") == "state_sync":
143
+ # Consumer receives initial state sync, store it and wait for joined message
144
+ logger.debug("Received initial state sync")
145
+ initial_state_sync = response
146
+ # Wait for the joined message
147
+ response_text = await asyncio.wait_for(
148
+ self.websocket.recv(), timeout=5.0
149
+ )
150
+ response = json.loads(response_text)
151
+ if response.get("type") == "joined":
152
+ logger.info(f"Successfully joined room {room_id} as {role}")
153
+ elif response.get("type") == "error":
154
+ logger.error(
155
+ f"Server rejected connection: {response.get('message')}"
156
+ )
157
+ await self.websocket.close()
158
+ return False
159
+ else:
160
+ logger.warning(f"Unexpected response from server: {response}")
161
+ elif response.get("type") == "joined":
162
+ logger.info(f"Successfully joined room {room_id} as {role}")
163
+ # Connection successful, continue with setup
164
+ else:
165
+ logger.warning(f"Unexpected response from server: {response}")
166
+
167
+ except TimeoutError:
168
+ logger.error("Timeout waiting for server response")
169
+ await self.websocket.close()
170
+ return False
171
+ except json.JSONDecodeError:
172
+ logger.error("Invalid JSON response from server")
173
+ await self.websocket.close()
174
+ return False
175
+
176
+ # Start message handling task
177
+ self._message_task = asyncio.create_task(self._handle_messages())
178
+
179
+ self.connected = True
180
+ logger.info(f"Connected to room {room_id} as {role}")
181
+
182
+ await self._on_connected()
183
+
184
+ # Process initial state sync if we received one
185
+ if initial_state_sync:
186
+ await self._process_message(initial_state_sync)
187
+
188
+ return True
189
+
190
+ except Exception as e:
191
+ logger.error(f"Failed to connect to room {room_id}: {e}")
192
+ return False
193
+
194
+ async def disconnect(self):
195
+ """Disconnect from current room"""
196
+ if self._message_task:
197
+ self._message_task.cancel()
198
+ try:
199
+ await self._message_task
200
+ except asyncio.CancelledError:
201
+ pass
202
+ self._message_task = None
203
+
204
+ if self.websocket:
205
+ await self.websocket.close()
206
+ self.websocket = None
207
+
208
+ self.connected = False
209
+ self.workspace_id = None
210
+ self.room_id = None
211
+ self.role = None
212
+ self.participant_id = None
213
+
214
+ await self._on_disconnected()
215
+
216
+ logger.info("Disconnected from room")
217
+
218
+ # ============= MESSAGE HANDLING =============
219
+
220
+ async def _handle_messages(self):
221
+ """Handle incoming WebSocket messages"""
222
+ try:
223
+ async for message in self.websocket:
224
+ try:
225
+ data = json.loads(message)
226
+ await self._process_message(data)
227
+ except json.JSONDecodeError:
228
+ logger.error(f"Invalid JSON received: {message}")
229
+ except Exception as e:
230
+ logger.error(f"Error processing message: {e}")
231
+
232
+ except websockets.exceptions.ConnectionClosed:
233
+ logger.info("WebSocket connection closed")
234
+ except Exception as e:
235
+ logger.error(f"WebSocket error: {e}")
236
+ finally:
237
+ self.connected = False
238
+ await self._on_disconnected()
239
+
240
+ async def _process_message(self, data: dict):
241
+ """Process incoming message based on type - to be overridden by subclasses"""
242
+ msg_type = data.get("type")
243
+
244
+ if msg_type == "joined":
245
+ logger.info(
246
+ f"Successfully joined room {data.get('room_id')} as {data.get('role')}"
247
+ )
248
+ elif msg_type == "heartbeat_ack":
249
+ logger.debug("Heartbeat acknowledged")
250
+ else:
251
+ # Let subclasses handle specific message types
252
+ await self._handle_role_specific_message(data)
253
+
254
+ async def _handle_role_specific_message(self, data: dict):
255
+ """Handle role-specific messages - to be overridden by subclasses"""
256
+
257
+ # ============= UTILITY METHODS =============
258
+
259
+ async def send_heartbeat(self):
260
+ """Send heartbeat to server"""
261
+ if not self.connected:
262
+ return
263
+
264
+ message = {"type": "heartbeat"}
265
+ await self.websocket.send(json.dumps(message))
266
+
267
+ def is_connected(self) -> bool:
268
+ """Check if client is connected"""
269
+ return self.connected
270
+
271
+ def get_connection_info(self) -> dict:
272
+ """Get current connection information"""
273
+ return {
274
+ "connected": self.connected,
275
+ "workspace_id": self.workspace_id,
276
+ "room_id": self.room_id,
277
+ "role": self.role,
278
+ "participant_id": self.participant_id,
279
+ "base_url": self.base_url,
280
+ }
281
+
282
+ # ============= HOOKS FOR SUBCLASSES =============
283
+
284
+ async def _on_connected(self):
285
+ """Called when connection is established - to be overridden by subclasses"""
286
+
287
+ async def _on_disconnected(self):
288
+ """Called when connection is lost - to be overridden by subclasses"""
289
+
290
+ # ============= CONTEXT MANAGER SUPPORT =============
291
+
292
+ async def __aenter__(self):
293
+ return self
294
+
295
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
296
+ await self.disconnect()
297
+
298
+ # ============= WORKSPACE HELPERS =============
299
+
300
+ def _generate_workspace_id(self) -> str:
301
+ """Generate a UUID-like workspace ID"""
302
+ import uuid
303
+
304
+ return str(uuid.uuid4())
305
+
306
+
307
+ class RoboticsProducer(RoboticsClientCore):
308
+ """Producer client for controlling robots"""
309
+
310
+ def __init__(self, base_url: str = "http://localhost:8000"):
311
+ super().__init__(base_url)
312
+ self._on_error_callback: Callable[[str], None] | None = None
313
+ self._on_connected_callback: Callable[[], None] | None = None
314
+ self._on_disconnected_callback: Callable[[], None] | None = None
315
+
316
+ async def connect(
317
+ self, workspace_id: str, room_id: str, participant_id: str | None = None
318
+ ) -> bool:
319
+ """Connect as producer to a room"""
320
+ return await self.connect_to_room(
321
+ workspace_id, room_id, "producer", participant_id
322
+ )
323
+
324
+ # ============= PRODUCER METHODS =============
325
+
326
+ async def send_joint_update(self, joints: list[dict]):
327
+ """Send joint updates"""
328
+ if not self.connected:
329
+ raise ValueError("Must be connected to send joint updates")
330
+
331
+ message = {"type": "joint_update", "data": joints}
332
+ await self.websocket.send(json.dumps(message))
333
+
334
+ async def send_state_sync(self, state: dict):
335
+ """Send state synchronization (convert dict to list format)"""
336
+ joints = [{"name": name, "value": value} for name, value in state.items()]
337
+ await self.send_joint_update(joints)
338
+
339
+ async def send_emergency_stop(self, reason: str = "Emergency stop"):
340
+ """Send emergency stop signal"""
341
+ if not self.connected:
342
+ raise ValueError("Must be connected to send emergency stop")
343
+
344
+ message = {"type": "emergency_stop", "reason": reason}
345
+ await self.websocket.send(json.dumps(message))
346
+
347
+ # ============= EVENT CALLBACKS =============
348
+
349
+ def on_error(self, callback: Callable[[str], None]):
350
+ """Set callback for error events"""
351
+ self._on_error_callback = callback
352
+
353
+ def on_connected(self, callback: Callable[[], None]):
354
+ """Set callback for connection events"""
355
+ self._on_connected_callback = callback
356
+
357
+ def on_disconnected(self, callback: Callable[[], None]):
358
+ """Set callback for disconnection events"""
359
+ self._on_disconnected_callback = callback
360
+
361
+ # ============= OVERRIDDEN HOOKS =============
362
+
363
+ async def _on_connected(self):
364
+ if self._on_connected_callback:
365
+ self._on_connected_callback()
366
+
367
+ async def _on_disconnected(self):
368
+ if self._on_disconnected_callback:
369
+ self._on_disconnected_callback()
370
+
371
+ async def _handle_role_specific_message(self, data: dict):
372
+ """Handle producer-specific messages"""
373
+ msg_type = data.get("type")
374
+
375
+ if msg_type == "emergency_stop":
376
+ logger.warning(f"🚨 Emergency stop: {data.get('reason', 'Unknown reason')}")
377
+ if self._on_error_callback:
378
+ self._on_error_callback(
379
+ f"Emergency stop: {data.get('reason', 'Unknown reason')}"
380
+ )
381
+
382
+ elif msg_type == "error":
383
+ error_msg = data.get("message", "Unknown error")
384
+ logger.error(f"Server error: {error_msg}")
385
+ if self._on_error_callback:
386
+ self._on_error_callback(error_msg)
387
+
388
+ else:
389
+ logger.warning(f"Unknown message type for producer: {msg_type}")
390
+
391
+
392
+ class RoboticsConsumer(RoboticsClientCore):
393
+ """Consumer client for receiving robot commands"""
394
+
395
+ def __init__(self, base_url: str = "http://localhost:8000"):
396
+ super().__init__(base_url)
397
+ self._on_state_sync_callback: Callable[[dict], None] | None = None
398
+ self._on_joint_update_callback: Callable[[list], None] | None = None
399
+ self._on_error_callback: Callable[[str], None] | None = None
400
+ self._on_connected_callback: Callable[[], None] | None = None
401
+ self._on_disconnected_callback: Callable[[], None] | None = None
402
+
403
+ async def connect(
404
+ self, workspace_id: str, room_id: str, participant_id: str | None = None
405
+ ) -> bool:
406
+ """Connect as consumer to a room"""
407
+ return await self.connect_to_room(
408
+ workspace_id, room_id, "consumer", participant_id
409
+ )
410
+
411
+ # ============= CONSUMER METHODS =============
412
+
413
+ async def get_state_sync(self) -> dict:
414
+ """Get current state synchronously"""
415
+ if not self.workspace_id or not self.room_id:
416
+ raise ValueError("Must be connected to a room")
417
+
418
+ state = await self.get_room_state(self.workspace_id, self.room_id)
419
+ return state.get("joints", {})
420
+
421
+ # ============= EVENT CALLBACKS =============
422
+
423
+ def on_state_sync(self, callback: Callable[[dict], None]):
424
+ """Set callback for state synchronization events"""
425
+ self._on_state_sync_callback = callback
426
+
427
+ def on_joint_update(self, callback: Callable[[list], None]):
428
+ """Set callback for joint update events"""
429
+ self._on_joint_update_callback = callback
430
+
431
+ def on_error(self, callback: Callable[[str], None]):
432
+ """Set callback for error events"""
433
+ self._on_error_callback = callback
434
+
435
+ def on_connected(self, callback: Callable[[], None]):
436
+ """Set callback for connection events"""
437
+ self._on_connected_callback = callback
438
+
439
+ def on_disconnected(self, callback: Callable[[], None]):
440
+ """Set callback for disconnection events"""
441
+ self._on_disconnected_callback = callback
442
+
443
+ # ============= OVERRIDDEN HOOKS =============
444
+
445
+ async def _on_connected(self):
446
+ if self._on_connected_callback:
447
+ self._on_connected_callback()
448
+
449
+ async def _on_disconnected(self):
450
+ if self._on_disconnected_callback:
451
+ self._on_disconnected_callback()
452
+
453
+ async def _handle_role_specific_message(self, data: dict):
454
+ """Handle consumer-specific messages"""
455
+ msg_type = data.get("type")
456
+
457
+ if msg_type == "state_sync":
458
+ if self._on_state_sync_callback:
459
+ self._on_state_sync_callback(data.get("data", {}))
460
+
461
+ elif msg_type == "joint_update":
462
+ if self._on_joint_update_callback:
463
+ self._on_joint_update_callback(data.get("data", []))
464
+
465
+ elif msg_type == "emergency_stop":
466
+ logger.warning(f"🚨 Emergency stop: {data.get('reason', 'Unknown reason')}")
467
+ if self._on_error_callback:
468
+ self._on_error_callback(
469
+ f"Emergency stop: {data.get('reason', 'Unknown reason')}"
470
+ )
471
+
472
+ elif msg_type == "error":
473
+ error_msg = data.get("message", "Unknown error")
474
+ logger.error(f"Server error: {error_msg}")
475
+ if self._on_error_callback:
476
+ self._on_error_callback(error_msg)
477
+
478
+ else:
479
+ logger.warning(f"Unknown message type for consumer: {msg_type}")
480
+
481
+
482
+ # ============= FACTORY FUNCTIONS =============
483
+
484
+
485
+ def create_client(role: str, base_url: str = "http://localhost:8000"):
486
+ """Factory function to create the appropriate client based on role"""
487
+ if role == "producer":
488
+ return RoboticsProducer(base_url)
489
+ if role == "consumer":
490
+ return RoboticsConsumer(base_url)
491
+ raise ValueError(f"Invalid role: {role}. Must be 'producer' or 'consumer'")
492
+
493
+
494
+ async def create_producer_client(
495
+ base_url: str = "http://localhost:8000",
496
+ workspace_id: str | None = None,
497
+ room_id: str | None = None,
498
+ ) -> RoboticsProducer:
499
+ """Create and connect a producer client"""
500
+ client = RoboticsProducer(base_url)
501
+
502
+ workspace_id, room_id = await client.create_room(workspace_id, room_id)
503
+ await client.connect(workspace_id, room_id)
504
+ return client
505
+
506
+
507
+ async def create_consumer_client(
508
+ workspace_id: str, room_id: str, base_url: str = "http://localhost:8000"
509
+ ) -> RoboticsConsumer:
510
+ """Create and connect a consumer client"""
511
+ client = RoboticsConsumer(base_url)
512
+ await client.connect(workspace_id, room_id)
513
+ return client
client/python/src/lerobot_arena_client/video/__init__.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LeRobot Arena Video Client - Main Module
3
+
4
+ TypeScript/JavaScript client for video streaming and monitoring
5
+ """
6
+
7
+ # Export core classes
8
+ from .consumer import VideoConsumer
9
+ from .core import VideoClientCore
10
+
11
+ # Export factory functions for convenience
12
+ from .factory import create_client, create_consumer_client, create_producer_client
13
+ from .producer import CameraVideoTrack, CustomVideoTrack, VideoProducer
14
+
15
+ # Export all types
16
+ from .types import (
17
+ DEFAULT_CLIENT_OPTIONS,
18
+ # Defaults
19
+ DEFAULT_RESOLUTION,
20
+ DEFAULT_VIDEO_CONFIG,
21
+ # API response types
22
+ ApiResponse,
23
+ # Message types
24
+ BaseMessage,
25
+ # Client options
26
+ ClientOptions,
27
+ ConnectedCallback,
28
+ ConnectionInfo,
29
+ # Request types
30
+ CreateRoomRequest,
31
+ CreateRoomResponse,
32
+ DeleteRoomResponse,
33
+ DisconnectedCallback,
34
+ EmergencyStopMessage,
35
+ ErrorCallback,
36
+ ErrorMessage,
37
+ # Data structures
38
+ FrameData,
39
+ # Event callback types
40
+ FrameUpdateCallback,
41
+ FrameUpdateMessage,
42
+ GetRoomResponse,
43
+ GetRoomStateResponse,
44
+ JoinedMessage,
45
+ JoinMessage,
46
+ ListRoomsResponse,
47
+ MessageType,
48
+ ParticipantInfo,
49
+ ParticipantJoinedMessage,
50
+ ParticipantLeftMessage,
51
+ # Core types
52
+ ParticipantRole,
53
+ RecoveryConfig,
54
+ RecoveryPolicy,
55
+ RecoveryTriggeredCallback,
56
+ RecoveryTriggeredMessage,
57
+ # Configuration types
58
+ Resolution,
59
+ RoomInfo,
60
+ RoomState,
61
+ StatusUpdateCallback,
62
+ StatusUpdateMessage,
63
+ StreamStartedCallback,
64
+ StreamStartedMessage,
65
+ StreamStats,
66
+ StreamStatsCallback,
67
+ StreamStatsMessage,
68
+ StreamStoppedCallback,
69
+ StreamStoppedMessage,
70
+ VideoConfig,
71
+ VideoConfigUpdateCallback,
72
+ VideoConfigUpdateMessage,
73
+ VideoEncoding,
74
+ WebRTCAnswerMessage,
75
+ # WebRTC types
76
+ WebRTCConfig,
77
+ WebRTCIceMessage,
78
+ WebRTCOfferMessage,
79
+ WebRTCSignalRequest,
80
+ WebRTCSignalResponse,
81
+ WebRTCStats,
82
+ )
83
+
84
+ __all__ = [
85
+ "DEFAULT_CLIENT_OPTIONS",
86
+ # Defaults
87
+ "DEFAULT_RESOLUTION",
88
+ "DEFAULT_VIDEO_CONFIG",
89
+ # API response types
90
+ "ApiResponse",
91
+ # Message types
92
+ "BaseMessage",
93
+ "CameraVideoTrack",
94
+ # Client options
95
+ "ClientOptions",
96
+ "ConnectedCallback",
97
+ "ConnectionInfo",
98
+ # Request types
99
+ "CreateRoomRequest",
100
+ "CreateRoomResponse",
101
+ "CustomVideoTrack",
102
+ "DeleteRoomResponse",
103
+ "DisconnectedCallback",
104
+ "EmergencyStopMessage",
105
+ "ErrorCallback",
106
+ "ErrorMessage",
107
+ # Data structures
108
+ "FrameData",
109
+ # Event callback types
110
+ "FrameUpdateCallback",
111
+ "FrameUpdateMessage",
112
+ "GetRoomResponse",
113
+ "GetRoomStateResponse",
114
+ "JoinMessage",
115
+ "JoinedMessage",
116
+ "ListRoomsResponse",
117
+ "MessageType",
118
+ "ParticipantInfo",
119
+ "ParticipantJoinedMessage",
120
+ "ParticipantLeftMessage",
121
+ # Core types
122
+ "ParticipantRole",
123
+ "RecoveryConfig",
124
+ "RecoveryPolicy",
125
+ "RecoveryTriggeredCallback",
126
+ "RecoveryTriggeredMessage",
127
+ # Configuration types
128
+ "Resolution",
129
+ "RoomInfo",
130
+ "RoomState",
131
+ "StatusUpdateCallback",
132
+ "StatusUpdateMessage",
133
+ "StreamStartedCallback",
134
+ "StreamStartedMessage",
135
+ "StreamStats",
136
+ "StreamStatsCallback",
137
+ "StreamStatsMessage",
138
+ "StreamStoppedCallback",
139
+ "StreamStoppedMessage",
140
+ # Core classes
141
+ "VideoClientCore",
142
+ "VideoConfig",
143
+ "VideoConfigUpdateCallback",
144
+ "VideoConfigUpdateMessage",
145
+ "VideoConsumer",
146
+ "VideoEncoding",
147
+ "VideoProducer",
148
+ "WebRTCAnswerMessage",
149
+ # WebRTC types
150
+ "WebRTCConfig",
151
+ "WebRTCIceMessage",
152
+ "WebRTCOfferMessage",
153
+ "WebRTCSignalRequest",
154
+ "WebRTCSignalResponse",
155
+ "WebRTCStats",
156
+ "create_client",
157
+ "create_consumer_client",
158
+ # Factory functions
159
+ "create_producer_client",
160
+ ]
client/python/src/lerobot_arena_client/video/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (2.64 kB). View file