Spaces:
Running
Running
Commit
·
02eac4b
0
Parent(s):
Initial commit
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .dockerignore +52 -0
- .github/workflows/docker.yml +120 -0
- Dockerfile +65 -0
- README.md +353 -0
- client/js/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc +98 -0
- client/js/.gitignore +34 -0
- client/js/README.md +396 -0
- client/js/bun.lock +409 -0
- client/js/examples/basic-consumer.js +173 -0
- client/js/examples/basic-producer.js +144 -0
- client/js/package.json +77 -0
- client/js/src/index.ts +12 -0
- client/js/src/robotics/consumer.ts +108 -0
- client/js/src/robotics/core.ts +295 -0
- client/js/src/robotics/factory.ts +66 -0
- client/js/src/robotics/index.ts +16 -0
- client/js/src/robotics/producer.ts +114 -0
- client/js/src/robotics/types.ts +180 -0
- client/js/src/video/consumer.ts +430 -0
- client/js/src/video/core.ts +533 -0
- client/js/src/video/factory.ts +66 -0
- client/js/src/video/index.ts +16 -0
- client/js/src/video/producer.ts +439 -0
- client/js/src/video/types.ts +352 -0
- client/js/tsconfig.json +32 -0
- client/js/vite.config.ts +38 -0
- client/python/.DS_Store +0 -0
- client/python/README.md +242 -0
- client/python/__pycache__/test_ai_camera.cpython-312-pytest-8.4.0.pyc +0 -0
- client/python/examples/README.md +189 -0
- client/python/examples/__pycache__/test_ai_server_consumer.cpython-312-pytest-8.4.0.pyc +0 -0
- client/python/examples/__pycache__/test_consumer_fix.cpython-312-pytest-8.4.0.pyc +0 -0
- client/python/examples/basic_consumer.py +92 -0
- client/python/examples/basic_producer.py +77 -0
- client/python/examples/consumer_first_recorder.py +319 -0
- client/python/examples/context_manager_example.py +177 -0
- client/python/examples/producer_consumer_demo.py +220 -0
- client/python/examples/room_management.py +93 -0
- client/python/examples/test_consumer_fix.py +214 -0
- client/python/examples/video_consumer_example.py +198 -0
- client/python/examples/video_producer_example.py +239 -0
- client/python/pyproject.toml +25 -0
- client/python/src/__pycache__/__init__.cpython-312.pyc +0 -0
- client/python/src/lerobot_arena_client/__init__.py +22 -0
- client/python/src/lerobot_arena_client/__pycache__/__init__.cpython-312.pyc +0 -0
- client/python/src/lerobot_arena_client/__pycache__/client.cpython-312.pyc +0 -0
- client/python/src/lerobot_arena_client/__pycache__/client.cpython-313.pyc +0 -0
- client/python/src/lerobot_arena_client/client.py +513 -0
- client/python/src/lerobot_arena_client/video/__init__.py +160 -0
- 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
|
|