diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..7cebfe8b0e78bfaa812631e6c1851009a0abe028 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,22 @@ +node_modules +.git +.gitignore +README.md +Dockerfile +docker-compose.yml +.dockerignore +.env +.env.local +.svelte-kit +dist +build +.vscode +*.log +.DS_Store +src-python/.venv +src-python/.git +src-python/target +src-python/dist +src-python/build +*.pyc +__pycache__ \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..c94badc6679d5e0d0f3d067543d6aa329df62b89 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.stl filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..41d5d064d8f378f924de2cdb7064d63b88355df4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* + +# Box-packager / PyApp build artifacts +src-python/target/ +src-python/dist/ +src-python/build/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000000000000000000000000000000000000..b6f27f135954640c8cc5bfd7b8c9922ca6eb2aad --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000000000000000000000000000000000000..6562bcbb8ab96e6800a4dcb41bed4ebb413bcbe2 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +# Package Managers +package-lock.json +pnpm-lock.yaml +yarn.lock +bun.lock +bun.lockb diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000000000000000000000000000000000000..7ebb855b9477e546f0acea1f628fc5d65ce916d5 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,15 @@ +{ + "useTabs": true, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100, + "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], + "overrides": [ + { + "files": "*.svelte", + "options": { + "parser": "svelte" + } + } + ] +} diff --git a/DOCKER_README.md b/DOCKER_README.md new file mode 100644 index 0000000000000000000000000000000000000000..8ecd292e7b7c8c441ab0e77bedf464e26a4776ac --- /dev/null +++ b/DOCKER_README.md @@ -0,0 +1,287 @@ +# ๐Ÿณ LeRobot Arena Docker Setup + +This Docker setup provides a containerized environment that runs both the Python backend and Svelte frontend for LeRobot Arena using modern tools like Bun, uv, and box-packager. + +## ๐Ÿš€ Quick Start + +### Using Docker Compose (Recommended) + +```bash +# Build and start both services +docker-compose up --build + +# Or run in detached mode +docker-compose up -d --build +``` + +### Using Docker directly + +```bash +# Build the image +docker build -t lerobot-arena . + +# Run the container +docker run -p 8080:8080 -p 7860:7860 lerobot-arena +``` + +## ๐ŸŒ Access the Application + +After starting the container, you can access: + +- **Frontend (Svelte)**: http://localhost:7860 +- **Backend (Python/FastAPI)**: http://localhost:8080 +- **Backend API Docs**: http://localhost:8080/docs + +## ๐Ÿ“‹ What's Included + +The Docker container includes: + +1. **Frontend**: + - Svelte application built as static files using **Bun** + - Served on port 7860 using Python's built-in HTTP server + - Production-ready build with all optimizations + +2. **Backend**: + - FastAPI Python server with dependencies managed by **uv** + - **Standalone executable** created with **box-packager** for faster startup + - WebSocket support for real-time robot communication + - Runs on port 8080 + - Auto-configured for container environment + +## ๐Ÿ› ๏ธ Development vs Production + +### Production (Default) +The Dockerfile builds the Svelte app as static files using Bun and packages the Python backend as a standalone executable using box-packager. + +### Development +For development with hot-reload, you can use local tools: + +```bash +# Terminal 1: Frontend development +bun run dev + +# Terminal 2: Backend development +cd src-python && python start_server.py +``` + +## ๐Ÿ“ Container Structure + +``` +/home/user/app/ +โ”œโ”€โ”€ src-python/ # Python backend source +โ”‚ โ””โ”€โ”€ target/release/ # Box-packager standalone executable +โ”œโ”€โ”€ static-frontend/ # Built Svelte app (static files) +โ””โ”€โ”€ start_services.sh # Startup script for both services +``` + +## ๐Ÿ”ง Build Process + +The Docker build process includes these key steps: + +1. **Frontend Build Stage (Bun)**: + - Uses `oven/bun:1-alpine` for fast package management + - Builds Svelte app with `bun run build` + - Produces optimized static files + +2. **Backend Build Stage (Python + Rust)**: + - Installs Rust/Cargo for box-packager + - Uses `uv` for fast Python dependency management + - Packages backend with `box package` into standalone executable + - Configures proper user permissions for HF Spaces + +3. **Runtime**: + - Runs standalone executable (faster startup) + - Serves frontend static files + - Both services managed by startup script + +## ๐Ÿš€ Performance Benefits + +### Box-packager Advantages: +- **Faster startup**: No Python interpreter overhead +- **Self-contained**: All dependencies bundled +- **Smaller runtime**: No need for full Python environment +- **Cross-platform**: Single executable works anywhere + +### Build Optimization: +- **Bun**: Faster JavaScript package manager and bundler +- **uv**: Ultra-fast Python package manager +- **Multi-stage build**: Minimal final image size +- **Alpine base**: Lightweight frontend build stage + +## ๐Ÿ”ง Customization + +### Environment Variables + +You can customize the container behavior using environment variables: + +```bash +docker run -p 8080:8080 -p 7860:7860 \ + -e PYTHONUNBUFFERED=1 \ + -e NODE_ENV=production \ + lerobot-arena +``` + +### Port Configuration + +To use different ports: + +```bash +# Map to different host ports +docker run -p 9000:8080 -p 3000:7860 lerobot-arena +``` + +Then access: +- Frontend: http://localhost:3000 +- Backend: http://localhost:9000 + +### Volume Mounts for Persistence + +```bash +# Mount data directory for persistence +docker run -p 8080:8080 -p 7860:7860 \ + -v $(pwd)/data:/home/user/app/data \ + lerobot-arena +``` + +## ๐Ÿ› Troubleshooting + +### Container won't start +```bash +# Check logs +docker-compose logs lerobot-arena + +# Or for direct docker run +docker logs +``` + +### Port conflicts +```bash +# Check what's using the ports +lsof -i :8080 +lsof -i :7860 + +# Kill processes or use different ports +docker run -p 8081:8080 -p 7861:7860 lerobot-arena +``` + +### Frontend not loading +```bash +# Verify the frontend was built correctly +docker exec -it ls -la /home/user/app/static-frontend + +# Check if frontend server is running +docker exec -it ps aux | grep python +``` + +### Backend API errors +```bash +# Check if standalone executable is running +docker exec -it ps aux | grep lerobot-arena-server + +# Test backend directly +curl http://localhost:8080/ +``` + +### Box-packager Build Issues +```bash +# Force rebuild without cache to fix cargo/box issues +docker-compose build --no-cache +docker-compose up + +# Check if Rust/Cargo is properly installed +docker exec -it cargo --version +``` + +## ๐Ÿ”„ Updates and Rebuilding + +```bash +# Pull latest code and rebuild +git pull +docker-compose down +docker-compose up --build + +# Force rebuild without cache +docker-compose build --no-cache +docker-compose up +``` + +## ๐Ÿš€ Hugging Face Spaces Deployment + +This Docker setup is optimized for Hugging Face Spaces: + +### Key Features for HF Spaces: +- **Port 7860**: Uses the default HF Spaces port +- **User permissions**: Runs as user ID 1000 as required +- **Proper ownership**: All files owned by the user +- **Git support**: Includes git for dependency resolution +- **Standalone executable**: Faster cold starts on HF infrastructure + +### Deployment Steps: +1. Push your code to a GitHub repository +2. Create a new Space on Hugging Face Spaces +3. Connect your GitHub repository +4. The YAML frontmatter in README.md will auto-configure the Space +5. HF will build and deploy your Docker container automatically + +### Accessing on HF Spaces: +- Your Space URL will serve the frontend directly +- Backend API will be available at your-space-url/api/ (if using a reverse proxy) + +## ๐Ÿ”ง Advanced Configuration + +### Using with nginx (Production) +```nginx +server { + listen 80; + + # Serve frontend + location / { + proxy_pass http://localhost:7860; + } + + # Proxy API calls + location /api/ { + proxy_pass http://localhost:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } + + # WebSocket support + location /ws/ { + proxy_pass http://localhost:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} +``` + +## ๐Ÿ“Š Container Stats + +```bash +# Monitor resource usage +docker stats lerobot-arena + +# View container info +docker inspect lerobot-arena +``` + +## ๐Ÿงน Cleanup + +```bash +# Stop and remove containers +docker-compose down + +# Remove images +docker rmi lerobot-arena + +# Clean up unused images and containers +docker system prune -a +``` + +--- + +**Happy containerized robotics! ๐Ÿค–๐Ÿณ** \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..2a2695cf314d58c98b34189c28bb0f6e7fb1acd3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,71 @@ +# Multi-stage Dockerfile for LeRobot Arena +# Stage 1: Build Svelte frontend with Bun +FROM oven/bun:1-alpine AS frontend-builder + +WORKDIR /app + +# Install git for dependencies that might need it +RUN apk add --no-cache git + +# Copy package files +COPY package.json bun.lockb* ./ + +# Install dependencies with bun +RUN bun install + +# Copy source code +COPY . . + +# Build the Svelte app +RUN bun run build + +# Stage 2: Python backend with official uv image +FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim + +# Set up a new user named "user" with user ID 1000 (required for HF Spaces) +RUN useradd -m -u 1000 user + +# Switch to the "user" user +USER user + +# Set home to the user's home directory +ENV HOME=/home/user \ + PATH=/home/user/.local/bin:$PATH + +# Set the working directory to the user's home directory +WORKDIR $HOME/app + +# Copy Python project files for dependency resolution +COPY --chown=user src-python/pyproject.toml src-python/uv.lock ./src-python/ + +# Install dependencies first (better caching) +WORKDIR $HOME/app/src-python +RUN uv sync --no-install-project + +# Copy the rest of the Python backend +COPY --chown=user src-python/ ./ + +# Install the project itself +RUN uv sync + +# Copy built frontend from previous stage with proper ownership +COPY --chown=user --from=frontend-builder /app/build $HOME/app/static-frontend + +# Create a startup script that runs both services +WORKDIR $HOME/app +RUN echo '#!/bin/bash\n\ +echo "Starting LeRobot Arena services..."\n\ +echo "๐Ÿš€ Starting Python backend on port 8080..."\n\ +cd $HOME/app/src-python && uv run python start_server.py &\n\ +echo "๐ŸŒ Starting static file server on port 7860..."\n\ +cd $HOME/app/static-frontend && python -m http.server 7860 --bind 0.0.0.0 &\n\ +echo "โœ… Both services started!"\n\ +echo "Backend: http://localhost:8080"\n\ +echo "Frontend: http://localhost:7860"\n\ +wait' > start_services.sh && chmod +x start_services.sh + +# Expose both ports (7860 is default for HF Spaces) +EXPOSE 7860 8080 + +# Start both services +CMD ["./start_services.sh"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..2253a49f3e938cd9bc4cf6ea359df21327780719 --- /dev/null +++ b/README.md @@ -0,0 +1,265 @@ +--- +title: LeRobot Arena +emoji: ๐Ÿค– +colorFrom: blue +colorTo: purple +sdk: docker +app_port: 7860 +suggested_hardware: cpu-upgrade +suggested_storage: small +short_description: A web-based robotics control and simulation platform that bridges digital twins and physical robots +tags: + - robotics + - control + - simulation + - websocket + - fastapi + - svelte + - urdf + - 3d-visualization +pinned: false +fullWidth: true +--- + +# ๐Ÿค– LeRobot Arena + +A web-based robotics control and simulation platform that bridges digital twins and physical robots. Built with Svelte for the frontend and FastAPI for the backend. + +## ๐Ÿš€ Quick Start with Docker + +The easiest way to run LeRobot Arena is using Docker, which sets up both the frontend and backend automatically. + +### Prerequisites + +- [Docker](https://www.docker.com/get-started) installed on your system +- [Docker Compose](https://docs.docker.com/compose/install/) (usually included with Docker Desktop) + +### Step-by-Step Instructions + +1. **Clone the repository** + ```bash + git clone + cd lerobot-arena + ``` + +2. **Build and start the services** + ```bash + docker-compose up --build + ``` + +3. **Access the application** + - **Frontend**: http://localhost:7860 + - **Backend API**: http://localhost:8080 + - **API Documentation**: http://localhost:8080/docs + +4. **Stop the services** + ```bash + # Press Ctrl+C to stop, or in another terminal: + docker-compose down + ``` + +### Alternative Docker Commands + +If you prefer using Docker directly: + +```bash +# Build the image +docker build -t lerobot-arena . + +# Run the container +docker run -p 8080:8080 -p 7860:7860 lerobot-arena +``` + +## ๐Ÿ› ๏ธ Development Setup + +For local development with hot-reload capabilities: + +### Frontend Development + +```bash +# Install dependencies +bun install + +# Start the development server +bun run dev + +# Or open in browser automatically +bun run dev -- --open +``` + +### Backend Development + +```bash +# Navigate to Python backend +cd src-python + +# Install Python dependencies (using uv) +uv sync + +# Or using pip +pip install -e . + +# Start the backend server +python start_server.py +``` + +### Building Standalone Executable + +The backend can be packaged as a standalone executable using box-packager: + +```bash +# Navigate to Python backend +cd src-python + +# Install box-packager (if not already installed) +uv pip install box-packager + +# Package the application +box package + +# The executable will be in target/release/lerobot-arena-server +./target/release/lerobot-arena-server +``` + +Note: Requires [Rust/Cargo](https://rustup.rs/) to be installed for box-packager to work. + +## ๐Ÿ“‹ Project Structure + +``` +lerobot-arena/ +โ”œโ”€โ”€ src/ # Svelte frontend source +โ”‚ โ”œโ”€โ”€ lib/ # Reusable components and utilities +โ”‚ โ”œโ”€โ”€ routes/ # SvelteKit routes +โ”‚ โ””โ”€โ”€ app.html # App template +โ”œโ”€โ”€ src-python/ # Python backend +โ”‚ โ”œโ”€โ”€ src/ # Python source code +โ”‚ โ”œโ”€โ”€ start_server.py # Server entry point +โ”‚ โ”œโ”€โ”€ target/ # Box-packager build output (excluded from git) +โ”‚ โ””โ”€โ”€ pyproject.toml # Python dependencies +โ”œโ”€โ”€ static/ # Static assets +โ”œโ”€โ”€ Dockerfile # Docker configuration +โ”œโ”€โ”€ docker-compose.yml # Docker Compose setup +โ””โ”€โ”€ package.json # Node.js dependencies +``` + +## ๐Ÿณ Docker Information + +The Docker setup includes: + +- **Multi-stage build**: Optimized for production using Bun and uv +- **Automatic startup**: Both services start together +- **Port mapping**: Backend on 8080, Frontend on 7860 (HF Spaces compatible) +- **Static file serving**: Compiled Svelte app served efficiently +- **User permissions**: Properly configured for Hugging Face Spaces +- **Standalone executable**: Backend packaged with box-packager for faster startup + +For detailed Docker documentation, see [DOCKER_README.md](./DOCKER_README.md). + +## ๐Ÿ”ง Building for Production + +### Frontend Only +```bash +bun run build +``` + +### Backend Standalone Executable +```bash +cd src-python +box package +``` + +### Complete Docker Build +```bash +docker-compose up --build +``` + +## ๐ŸŒ What's Included + +- **Real-time Robot Control**: WebSocket-based communication +- **3D Visualization**: Three.js integration for robot visualization +- **URDF Support**: Load and display robot models +- **Multi-robot Management**: Control multiple robots simultaneously +- **WebSocket API**: Real-time bidirectional communication +- **Standalone Distribution**: Self-contained executable with box-packager + +## ๐Ÿšจ Troubleshooting + +### Port Conflicts +If ports 8080 or 7860 are already in use: + +```bash +# Check what's using the ports +lsof -i :8080 +lsof -i :7860 + +# Use different ports +docker run -p 8081:8080 -p 7861:7860 lerobot-arena +``` + +### Container Issues +```bash +# View logs +docker-compose logs lerobot-arena + +# Rebuild without cache +docker-compose build --no-cache +docker-compose up +``` + +### Development Issues +```bash +# Clear node modules and reinstall +rm -rf node_modules +bun install + +# Clear Svelte kit cache +rm -rf .svelte-kit +bun run dev +``` + +### Box-packager Issues +```bash +# Clean build artifacts +cd src-python +box clean + +# Rebuild executable +box package + +# Install cargo if missing +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +``` + +## ๐Ÿš€ Hugging Face Spaces Deployment + +This project is configured for deployment on Hugging Face Spaces: + +1. **Fork** this repository to your GitHub account +2. **Create a new Space** on Hugging Face Spaces +3. **Connect** your GitHub repository +4. **Select Docker SDK** (should be auto-detected from the frontmatter) +5. **Deploy** - The Space will automatically build and run + +The frontend will be available at your Space URL, and the backend API will be accessible at `/api/` endpoints. + +## ๐Ÿ“š Additional Documentation + +- [Docker Setup Guide](./DOCKER_README.md) - Detailed Docker instructions +- [Robot Architecture](./ROBOT_ARCHITECTURE.md) - System architecture overview +- [Robot Instancing Guide](./ROBOT_INSTANCING_README.md) - Multi-robot setup + +## ๐Ÿค Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Test with Docker: `docker-compose up --build` +5. Submit a pull request + +## ๐Ÿ“„ License + +This project is licensed under the MIT License. + +--- + +**Built with โค๏ธ for the robotics community** ๐Ÿค– diff --git a/ROBOT_ARCHITECTURE.md b/ROBOT_ARCHITECTURE.md new file mode 100644 index 0000000000000000000000000000000000000000..9b580b9aaca06a409a7ebfc8430a43e0fe200ab9 --- /dev/null +++ b/ROBOT_ARCHITECTURE.md @@ -0,0 +1,416 @@ +# Robot Control Architecture v2.0 - Master/Slave Pattern + +This document describes the revolutionary new **Master-Slave Architecture** that enables sophisticated robot control with clear separation between command sources (Masters) and execution targets (Slaves). + +## ๐Ÿ—๏ธ Architecture Overview + +The new architecture follows a **Master-Slave Pattern** with complete separation of concerns: + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ UI Panel โ”‚ โ”‚ Robot Manager โ”‚ โ”‚ Masters โ”‚ +โ”‚ โ”‚โ—„โ”€โ”€โ–บโ”‚ โ”‚โ—„โ”€โ”€โ–บโ”‚ โ”‚ +โ”‚ โ€ข Manual Controlโ”‚ โ”‚ โ€ข Master/Slave โ”‚ โ”‚ โ€ข Remote Server โ”‚ +โ”‚ โ€ข Monitoring โ”‚ โ”‚ โ€ข Command Router โ”‚ โ”‚ โ€ข Mock Sequence โ”‚ +โ”‚ (disabled if โ”‚ โ”‚ โ€ข State Sync โ”‚ โ”‚ โ€ข Script Player โ”‚ +โ”‚ master active) โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ + โ–ผ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ Robot Model โ”‚โ—„โ”€โ”€โ–บโ”‚ Slaves โ”‚ + โ”‚ โ”‚ โ”‚ โ”‚ + โ”‚ โ€ข URDF State โ”‚ โ”‚ โ€ข USB Robots โ”‚ + โ”‚ โ€ข Joint States โ”‚ โ”‚ โ€ข Mock Hardware โ”‚ + โ”‚ โ€ข Command Queue โ”‚ โ”‚ โ€ข Simulations โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +## ๐ŸŽฏ Key Concepts + +### Masters (Command Sources) +- **Purpose**: Generate commands and control sequences +- **Examples**: Remote servers, predefined sequences, scripts +- **Limitation**: Only 1 master per robot (exclusive control) +- **Effect**: When active, disables manual panel control + +### Slaves (Execution Targets) +- **Purpose**: Execute commands on physical/virtual robots +- **Examples**: USB robots, mock robots, simulations +- **Limitation**: Multiple slaves per robot (parallel execution) +- **Effect**: All connected slaves execute the same commands + +### Control Flow +1. **No Master**: Manual panel control โ†’ All slaves +2. **With Master**: Master commands โ†’ All slaves (panel disabled) +3. **Bidirectional**: Slaves provide real-time feedback + +## ๐Ÿ“ File Structure + +``` +src/lib/ +โ”œโ”€โ”€ robot/ +โ”‚ โ”œโ”€โ”€ Robot.svelte.ts # Master-slave robot management +โ”‚ โ”œโ”€โ”€ RobotManager.svelte.ts # Global orchestration +โ”‚ โ””โ”€โ”€ drivers/ +โ”‚ โ”œโ”€โ”€ masters/ +โ”‚ โ”‚ โ”œโ”€โ”€ MockSequenceMaster.ts # Demo sequences +โ”‚ โ”‚ โ”œโ”€โ”€ RemoteServerMaster.ts # HTTP/WebSocket commands +โ”‚ โ”‚ โ””โ”€โ”€ ScriptPlayerMaster.ts # Script execution +โ”‚ โ””โ”€โ”€ slaves/ +โ”‚ โ”œโ”€โ”€ MockSlave.ts # Development/testing +โ”‚ โ”œโ”€โ”€ USBSlave.ts # Physical USB robots +โ”‚ โ””โ”€โ”€ SimulationSlave.ts # Physics simulation +โ”œโ”€โ”€ types/ +โ”‚ โ””โ”€โ”€ robotDriver.ts # Master/Slave interfaces +โ””โ”€โ”€ components/ + โ”œโ”€โ”€ scene/ + โ”‚ โ””โ”€โ”€ Robot.svelte # 3D visualization + โ””โ”€โ”€ panel/ + โ””โ”€โ”€ RobotControlPanel.svelte # Master/Slave connection UI +``` + +## ๐Ÿ”ง Core Components + +### RobotManager +**Central orchestrator with master-slave management** + +```typescript +import { robotManager } from '$lib/robot/RobotManager.svelte'; + +// Create robot +const robot = await robotManager.createRobot('my-robot', urdfConfig); + +// Connect demo sequence master (disables manual control) +await robotManager.connectDemoSequences('my-robot', true); + +// Connect mock slave (executes commands) +await robotManager.connectMockSlave('my-robot', 50); + +// Connect USB slave (real hardware) +await robotManager.connectUSBSlave('my-robot'); + +// Disconnect master (restores manual control) +await robotManager.disconnectMaster('my-robot'); +``` + +### Robot Class +**Individual robot with master-slave coordination** + +```typescript +// Master management +await robot.setMaster(sequenceMaster); +await robot.removeMaster(); + +// Slave management +await robot.addSlave(usbSlave); +await robot.addSlave(mockSlave); +await robot.removeSlave(slaveId); + +// Control state +robot.controlState.hasActiveMaster // true when master connected +robot.controlState.manualControlEnabled // false when master active +robot.controlState.lastCommandSource // "master" | "manual" | "none" + +// Manual control (only when no master) +await robot.updateJointValue('joint_1', 45); +``` + +## ๐Ÿ”Œ Master Drivers + +### Mock Sequence Master +**Predefined movement sequences for testing** + +```typescript +const config: MasterDriverConfig = { + type: "mock-sequence", + sequences: DEMO_SEQUENCES, // Wave, Scan, Pick-Place patterns + autoStart: true, + loopMode: true +}; + +await robotManager.connectMaster('robot-1', config); +``` + +**Demo Sequences:** +- **Wave Pattern**: Greeting gesture with wrist roll +- **Scanning Pattern**: Horizontal sweep with pitch variation +- **Pick and Place**: Complete manipulation sequence + +### Remote Server Master *(Planned)* +**HTTP/WebSocket command reception** + +```typescript +const config: MasterDriverConfig = { + type: "remote-server", + url: "ws://robot-server:8080/ws", + apiKey: "your-api-key", + pollInterval: 100 +}; +``` + +### Script Player Master *(Planned)* +**JavaScript/Python script execution** + +```typescript +const config: MasterDriverConfig = { + type: "script-player", + scriptUrl: "/scripts/robot-dance.js", + variables: { speed: 1.5, amplitude: 45 } +}; +``` + +## ๐Ÿค– Slave Drivers + +### Mock Slave +**Perfect simulation for development** + +```typescript +const config: SlaveDriverConfig = { + type: "mock-slave", + simulateLatency: 50, // ms response delay + simulateErrors: false, // random failures + responseDelay: 20 // command execution time +}; + +await robotManager.connectSlave('robot-1', config); +``` + +**Features:** +- โœ… Perfect command execution (real = virtual) +- โœ… Configurable latency and errors +- โœ… Real-time state feedback +- โœ… Instant connection + +### USB Slave *(Planned)* +**Physical robot via feetech.js** + +```typescript +const config: SlaveDriverConfig = { + type: "usb-slave", + port: "/dev/ttyUSB0", // auto-detect if undefined + baudRate: 115200 +}; +``` + +**Features:** +- โœ… Direct hardware control +- โœ… Position/speed commands +- โœ… Real servo feedback +- โœ… Calibration support + +### Simulation Slave *(Planned)* +**Physics-based simulation** + +```typescript +const config: SlaveDriverConfig = { + type: "simulation-slave", + physics: true, + collisionDetection: true +}; +``` + +## ๐Ÿ”„ Command Flow Architecture + +### Command Structure +```typescript +interface RobotCommand { + timestamp: number; + joints: { + name: string; + value: number; // degrees for revolute, speed for continuous + speed?: number; // optional movement speed + }[]; + duration?: number; // optional execution time + metadata?: Record; +} +``` + +### Command Sources + +#### Master Commands +```typescript +// Continuous sequence from master +master.onCommand((commands) => { + // Route to all connected slaves + robot.slaves.forEach(slave => slave.executeCommands(commands)); +}); +``` + +#### Manual Commands +```typescript +// Only when no master active +if (robot.manualControlEnabled) { + await robot.updateJointValue('shoulder', 45); +} +``` + +### Command Execution +```typescript +// All slaves execute in parallel +const executePromises = robot.connectedSlaves.map(slave => + slave.executeCommand(command) +); +await Promise.allSettled(executePromises); +``` + +## ๐ŸŽฎ Usage Examples + +### Basic Master-Slave Setup +```typescript +// 1. Create robot +const robot = await robotManager.createRobot('arm-1', urdfConfig); + +// 2. Add slaves for execution +await robotManager.connectMockSlave('arm-1'); // Development +await robotManager.connectUSBSlave('arm-1'); // Real hardware + +// 3. Connect master for control +await robotManager.connectDemoSequences('arm-1'); // Automated sequences + +// 4. Monitor status +const status = robotManager.getRobotStatus('arm-1'); +console.log(`Master: ${status.masterName}, Slaves: ${status.connectedSlaves}`); +``` + +### Multiple Robots with Different Masters +```typescript +// Robot 1: Demo sequences +await robotManager.createRobot('demo-1', armConfig); +await robotManager.connectDemoSequences('demo-1', true); +await robotManager.connectMockSlave('demo-1'); + +// Robot 2: Remote control +await robotManager.createRobot('remote-1', armConfig); +await robotManager.connectMaster('remote-1', remoteServerConfig); +await robotManager.connectUSBSlave('remote-1'); + +// Robot 3: Manual control only +await robotManager.createRobot('manual-1', armConfig); +await robotManager.connectMockSlave('manual-1'); +// No master = manual control enabled +``` + +### Master Switching +```typescript +// Switch from manual to automated control +await robotManager.connectDemoSequences('robot-1'); +// Panel inputs now disabled, sequences control robot + +// Switch back to manual control +await robotManager.disconnectMaster('robot-1'); +// Panel inputs re-enabled, manual control restored +``` + +## ๐Ÿš€ Benefits + +### 1. **Clear Control Hierarchy** +- Masters provide commands exclusively +- Slaves execute commands in parallel +- No ambiguity about command source + +### 2. **Flexible Command Sources** +- Remote servers for network control +- Predefined sequences for automation +- Manual control for testing/setup +- Easy to add new master types + +### 3. **Multiple Execution Targets** +- Physical robots via USB +- Simulated robots for testing +- Mock robots for development +- All execute same commands simultaneously + +### 4. **Automatic Panel Management** +- Panel disabled when master active +- Panel re-enabled when master disconnected +- Clear visual feedback about control state + +### 5. **Safe Operation** +- Masters cannot conflict (only 1 allowed) +- Graceful disconnection with rest positioning +- Error isolation between slaves + +### 6. **Development Workflow** +- Test with mock slaves during development +- Add real slaves for deployment +- Switch masters for different scenarios + +## ๐Ÿ”ฎ Implementation Roadmap + +### Phase 1: Core Architecture โœ… +- [x] Master-Slave interfaces +- [x] Robot class with dual management +- [x] RobotManager orchestration +- [x] MockSequenceMaster with demo patterns +- [x] MockSlave for testing + +### Phase 2: Real Hardware +- [ ] USBSlave implementation (feetech.js integration) +- [ ] Calibration system for hardware slaves +- [ ] Error handling and recovery + +### Phase 3: Remote Control +- [ ] RemoteServerMaster (HTTP/WebSocket) +- [ ] Authentication and security +- [ ] Real-time command streaming + +### Phase 4: Advanced Features +- [ ] ScriptPlayerMaster (JS/Python execution) +- [ ] SimulationSlave (physics integration) +- [ ] Command recording and playback +- [ ] Multi-robot coordination + +## ๐Ÿ›ก๏ธ Safety Features + +### Master Safety +- Only 1 master per robot prevents conflicts +- Master disconnect automatically restores manual control +- Command validation before execution + +### Slave Safety +- Rest positioning before disconnect +- Smooth movement transitions +- Individual slave error isolation +- Calibration offset compensation + +### System Safety +- Graceful degradation on slave failures +- Command queuing prevents overwhelming +- Real-time state monitoring +- Emergency stop capabilities *(planned)* + +## ๐Ÿ”ง Migration from v1.0 + +The new architecture is **completely different** from the old driver pattern: + +### Old Architecture (v1.0) +```typescript +// Single driver per robot +const driver = new USBRobotDriver(config); +await robot.setDriver(driver); +await robot.updateJointValue('joint', 45); +``` + +### New Architecture (v2.0) +```typescript +// Separate masters and slaves +await robotManager.connectMaster(robotId, masterConfig); // Command source +await robotManager.connectSlave(robotId, slaveConfig); // Execution target + +// Manual control only when no master +if (robot.manualControlEnabled) { + await robot.updateJointValue('joint', 45); +} +``` + +### Key Changes +1. **Drivers โ†’ Masters + Slaves**: Split command/execution concerns +2. **Single Connection โ†’ Multiple**: 1 master + N slaves per robot +3. **Always Manual โ†’ Conditional**: Panel disabled when master active +4. **Direct Control โ†’ Command Routing**: Masters route to all slaves + +The new architecture provides **dramatically more flexibility** while maintaining complete backward compatibility for URDF visualization and joint control. + +--- + +*This architecture enables sophisticated robot control scenarios from simple manual operation to complex multi-robot coordination, all with a clean, extensible design.* \ No newline at end of file diff --git a/ROBOT_INSTANCING_README.md b/ROBOT_INSTANCING_README.md new file mode 100644 index 0000000000000000000000000000000000000000..7835d8b6645feef4ebca3371dafcfbc008ba0fa9 --- /dev/null +++ b/ROBOT_INSTANCING_README.md @@ -0,0 +1,73 @@ +# Robot Instancing Optimization + +This implementation optimizes the rendering of multiple robots using Threlte's instancing capabilities for improved performance. + +## Key Features + +### 1. **Smart Instancing by Robot Type** +- Robots are grouped by their URDF type (URL) for optimal instancing +- Single robots render with full detail +- Multiple robots of the same type use instanced rendering + +### 2. **Geometry-Specific Instancing** +- **Box Geometries**: Instanced with `T.boxGeometry` +- **Cylinder Geometries**: Instanced with `T.cylinderGeometry` +- **Mesh Geometries**: Instanced with simplified `T.sphereGeometry` for performance + +### 3. **Hybrid Rendering Strategy** +- First robot of each type: Full detailed rendering with all URDF components +- Additional robots: Simplified instanced representation +- Maintains visual quality while optimizing performance + +### 4. **Performance Benefits** +- Reduces draw calls when rendering multiple robots +- Optimizes GPU memory usage through instancing +- Scales better with increasing robot count +- Maintains interactivity for detailed robots + +## Implementation Details + +### State Management +```typescript +// Robots grouped by URDF type for optimal batching +let robotsByType: Record> = $state({}); +``` + +### Instancing Logic +1. **Single Robot**: Full `UrdfLink` rendering with all details +2. **Multiple Robots**: + - Geometry analysis and grouping + - Instanced rendering for primitive shapes + - Simplified representations for complex meshes + +### Automatic Demonstration +- Spawns additional robots after 2 seconds to showcase instancing +- Shows performance difference between single and multiple robot rendering + +## Usage + +Simply use the `Robot.svelte` component with a `urdfConfig`. The component automatically: +- Detects when multiple robots of the same type exist +- Switches to instanced rendering for optimal performance +- Maintains full detail for single robots + +```svelte + +``` + +## Performance Impact + +- **Before**: O(n) draw calls for n robots +- **After**: O(1) draw calls per geometry type regardless of robot count +- **Memory**: Shared geometry instances reduce GPU memory usage +- **Scalability**: Linear performance improvement with robot count + +This optimization is particularly beneficial for: +- Robot swarms +- Multi-robot simulations +- Arena scenarios with many similar robots +- Performance-critical real-time applications \ No newline at end of file diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000000000000000000000000000000000000..3ecf5327066eccaca6cc8eef104b7d14e0f52472 --- /dev/null +++ b/bun.lock @@ -0,0 +1,652 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "my-app", + "dependencies": { + "@threlte/core": "^8.0.4", + "@threlte/extras": "^9.2.1", + "@types/three": "^0.176.0", + "clsx": "^2.1.1", + "tailwind-merge": "^3.3.0", + "three": "^0.177.0", + "zod": "^3.25.42", + }, + "devDependencies": { + "@eslint/compat": "^1.2.5", + "@eslint/js": "^9.18.0", + "@sveltejs/adapter-auto": "^6.0.0", + "@sveltejs/adapter-static": "^3.0.8", + "@sveltejs/kit": "^2.16.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@tailwindcss/vite": "^4.0.0", + "eslint": "^9.18.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-svelte": "^3.0.0", + "globals": "^16.0.0", + "prettier": "^3.4.2", + "prettier-plugin-svelte": "^3.3.3", + "prettier-plugin-tailwindcss": "^0.6.11", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.0.0", + "typescript-eslint": "^8.20.0", + "vite": "^6.2.6", + }, + }, + }, + "packages": { + "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], + + "@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.5", "", { "os": "android", "cpu": "arm" }, "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.5", "", { "os": "android", "cpu": "arm64" }, "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.5", "", { "os": "android", "cpu": "x64" }, "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.5", "", { "os": "linux", "cpu": "arm" }, "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.5", "", { "os": "linux", "cpu": "none" }, "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.5", "", { "os": "linux", "cpu": "none" }, "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.5", "", { "os": "linux", "cpu": "none" }, "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.5", "", { "os": "linux", "cpu": "x64" }, "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.5", "", { "os": "none", "cpu": "arm64" }, "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.5", "", { "os": "none", "cpu": "x64" }, "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.5", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.5", "", { "os": "win32", "cpu": "x64" }, "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="], + + "@eslint/compat": ["@eslint/compat@1.2.9", "", { "peerDependencies": { "eslint": "^9.10.0" }, "optionalPeers": ["eslint"] }, "sha512-gCdSY54n7k+driCadyMNv8JSPzYLeDVM/ikZRtvtROBpRdFSkS8W9A82MqsaY7lZuwL0wiapgD0NT1xT0hyJsA=="], + + "@eslint/config-array": ["@eslint/config-array@0.20.0", "", { "dependencies": { "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.2.2", "", {}, "sha512-+GPzk8PlG0sPpzdU5ZvIRMPidzAnZDl/s9L+y13iodqvb8leL53bTannOrQ/Im7UkpsmFU5Ily5U60LWixnmLg=="], + + "@eslint/core": ["@eslint/core@0.14.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg=="], + + "@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="], + + "@eslint/js": ["@eslint/js@9.28.0", "", {}, "sha512-fnqSjGWd/CoIp4EXIxWVK/sHA6DOHN4+8Ix2cX5ycOY7LG0UY8nHCU5pIp2eaE1Mc7Qd8kHspYNzYXT2ojPLzg=="], + + "@eslint/object-schema": ["@eslint/object-schema@2.1.6", "", {}, "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.3.1", "", { "dependencies": { "@eslint/core": "^0.14.0", "levn": "^0.4.1" } }, "sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w=="], + + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], + + "@humanfs/node": ["@humanfs/node@0.16.6", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/set-array": ["@jridgewell/set-array@1.2.1", "", {}, "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="], + + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.41.1", "", { "os": "android", "cpu": "arm" }, "sha512-NELNvyEWZ6R9QMkiytB4/L4zSEaBC03KIXEghptLGLZWJ6VPrL63ooZQCOnlx36aQPGhzuOMwDerC1Eb2VmrLw=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.41.1", "", { "os": "android", "cpu": "arm64" }, "sha512-DXdQe1BJ6TK47ukAoZLehRHhfKnKg9BjnQYUu9gzhI8Mwa1d2fzxA1aw2JixHVl403bwp1+/o/NhhHtxWJBgEA=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.41.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5afxvwszzdulsU2w8JKWwY8/sJOLPzf0e1bFuvcW5h9zsEg+RQAojdW0ux2zyYAz7R8HvvzKCjLNJhVq965U7w=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.41.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-egpJACny8QOdHNNMZKf8xY0Is6gIMz+tuqXlusxquWu3F833DcMwmGM7WlvCO9sB3OsPjdC4U0wHw5FabzCGZg=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.41.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-DBVMZH5vbjgRk3r0OzgjS38z+atlupJ7xfKIDJdZZL6sM6wjfDNo64aowcLPKIx7LMQi8vybB56uh1Ftck/Atg=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.41.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-3FkydeohozEskBxNWEIbPfOE0aqQgB6ttTkJ159uWOFn42VLyfAiyD9UK5mhu+ItWzft60DycIN1Xdgiy8o/SA=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.41.1", "", { "os": "linux", "cpu": "arm" }, "sha512-wC53ZNDgt0pqx5xCAgNunkTzFE8GTgdZ9EwYGVcg+jEjJdZGtq9xPjDnFgfFozQI/Xm1mh+D9YlYtl+ueswNEg=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.41.1", "", { "os": "linux", "cpu": "arm" }, "sha512-jwKCca1gbZkZLhLRtsrka5N8sFAaxrGz/7wRJ8Wwvq3jug7toO21vWlViihG85ei7uJTpzbXZRcORotE+xyrLA=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.41.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-g0UBcNknsmmNQ8V2d/zD2P7WWfJKU0F1nu0k5pW4rvdb+BIqMm8ToluW/eeRmxCared5dD76lS04uL4UaNgpNA=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.41.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-XZpeGB5TKEZWzIrj7sXr+BEaSgo/ma/kCgrZgL0oo5qdB1JlTzIYQKel/RmhT6vMAvOdM2teYlAaOGJpJ9lahg=="], + + "@rollup/rollup-linux-loongarch64-gnu": ["@rollup/rollup-linux-loongarch64-gnu@4.41.1", "", { "os": "linux", "cpu": "none" }, "sha512-bkCfDJ4qzWfFRCNt5RVV4DOw6KEgFTUZi2r2RuYhGWC8WhCA8lCAJhDeAmrM/fdiAH54m0mA0Vk2FGRPyzI+tw=="], + + "@rollup/rollup-linux-powerpc64le-gnu": ["@rollup/rollup-linux-powerpc64le-gnu@4.41.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3mr3Xm+gvMX+/8EKogIZSIEF0WUu0HL9di+YWlJpO8CQBnoLAEL/roTCxuLncEdgcfJcvA4UMOf+2dnjl4Ut1A=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.41.1", "", { "os": "linux", "cpu": "none" }, "sha512-3rwCIh6MQ1LGrvKJitQjZFuQnT2wxfU+ivhNBzmxXTXPllewOF7JR1s2vMX/tWtUYFgphygxjqMl76q4aMotGw=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.41.1", "", { "os": "linux", "cpu": "none" }, "sha512-LdIUOb3gvfmpkgFZuccNa2uYiqtgZAz3PTzjuM5bH3nvuy9ty6RGc/Q0+HDFrHrizJGVpjnTZ1yS5TNNjFlklw=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.41.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-oIE6M8WC9ma6xYqjvPhzZYk6NbobIURvP/lEbh7FWplcMO6gn7MM2yHKA1eC/GvYwzNKK/1LYgqzdkZ8YFxR8g=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.41.1", "", { "os": "linux", "cpu": "x64" }, "sha512-cWBOvayNvA+SyeQMp79BHPK8ws6sHSsYnK5zDcsC3Hsxr1dgTABKjMnMslPq1DvZIp6uO7kIWhiGwaTdR4Og9A=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.41.1", "", { "os": "linux", "cpu": "x64" }, "sha512-y5CbN44M+pUCdGDlZFzGGBSKCA4A/J2ZH4edTYSSxFg7ce1Xt3GtydbVKWLlzL+INfFIZAEg1ZV6hh9+QQf9YQ=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.41.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lZkCxIrjlJlMt1dLO/FbpZbzt6J/A8p4DnqzSa4PWqPEUUUnzXLeki/iyPLfV0BmHItlYgHUqJe+3KiyydmiNQ=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.41.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-+psFT9+pIh2iuGsxFYYa/LhS5MFKmuivRsx9iPJWNSGbh2XVEjk90fmpUEjCnILPEPJnikAU6SFDiEUyOv90Pg=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.41.1", "", { "os": "win32", "cpu": "x64" }, "sha512-Wq2zpapRYLfi4aKxf2Xff0tN+7slj2d4R87WEzqw7ZLsVvO5zwYCIuEGSZYiK41+GlwUo1HiR+GdkLEJnCKTCw=="], + + "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.5", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ=="], + + "@sveltejs/adapter-auto": ["@sveltejs/adapter-auto@6.0.1", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-mcWud3pYGPWM2Pphdj8G9Qiq24nZ8L4LB7coCUckUEy5Y7wOWGJ/enaZ4AtJTcSm5dNK1rIkBRoqt+ae4zlxcQ=="], + + "@sveltejs/adapter-static": ["@sveltejs/adapter-static@3.0.8", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-YaDrquRpZwfcXbnlDsSrBQNCChVOT9MGuSg+dMAyfsAa1SmiAhrA5jUYUiIMC59G92kIbY/AaQOWcBdq+lh+zg=="], + + "@sveltejs/kit": ["@sveltejs/kit@2.21.1", "", { "dependencies": { "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.1.0", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.3 || ^6.0.0" }, "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-vLbtVwtDcK8LhJKnFkFYwM0uCdFmzioQnif0bjEYH1I24Arz22JPr/hLUiXGVYAwhu8INKx5qrdvr4tHgPwX6w=="], + + "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@5.0.3", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.0", "deepmerge": "^4.3.1", "kleur": "^4.1.5", "magic-string": "^0.30.15", "vitefu": "^1.0.4" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-MCFS6CrQDu1yGwspm4qtli0e63vaPCehf6V7pIMP15AsWgMKrqDGCPFF/0kn4SP0ii4aySu4Pa62+fIRGFMjgw=="], + + "@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@4.0.1", "", { "dependencies": { "debug": "^4.3.7" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^5.0.0", "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.1.8", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.8" } }, "sha512-OWwBsbC9BFAJelmnNcrKuf+bka2ZxCE2A4Ft53Tkg4uoiE67r/PMEYwCsourC26E+kmxfwE0hVzMdxqeW+xu7Q=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.8", "", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.4.3" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.8", "@tailwindcss/oxide-darwin-arm64": "4.1.8", "@tailwindcss/oxide-darwin-x64": "4.1.8", "@tailwindcss/oxide-freebsd-x64": "4.1.8", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.8", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.8", "@tailwindcss/oxide-linux-arm64-musl": "4.1.8", "@tailwindcss/oxide-linux-x64-gnu": "4.1.8", "@tailwindcss/oxide-linux-x64-musl": "4.1.8", "@tailwindcss/oxide-wasm32-wasi": "4.1.8", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.8", "@tailwindcss/oxide-win32-x64-msvc": "4.1.8" } }, "sha512-d7qvv9PsM5N3VNKhwVUhpK6r4h9wtLkJ6lz9ZY9aeZgrUWk1Z8VPyqyDT9MZlem7GTGseRQHkeB1j3tC7W1P+A=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.8", "", { "os": "android", "cpu": "arm64" }, "sha512-Fbz7qni62uKYceWYvUjRqhGfZKwhZDQhlrJKGtnZfuNtHFqa8wmr+Wn74CTWERiW2hn3mN5gTpOoxWKk0jRxjg=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RdRvedGsT0vwVVDztvyXhKpsU2ark/BjgG0huo4+2BluxdXo8NDgzl77qh0T1nUxmM11eXwR8jA39ibvSTbi7A=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-t6PgxjEMLp5Ovf7uMb2OFmb3kqzVTPPakWpBIFzppk4JE4ix0yEtbtSjPbU8+PZETpaYMtXvss2Sdkx8Vs4XRw=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.8", "", { "os": "freebsd", "cpu": "x64" }, "sha512-g8C8eGEyhHTqwPStSwZNSrOlyx0bhK/V/+zX0Y+n7DoRUzyS8eMbVshVOLJTDDC+Qn9IJnilYbIKzpB9n4aBsg=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.8", "", { "os": "linux", "cpu": "arm" }, "sha512-Jmzr3FA4S2tHhaC6yCjac3rGf7hG9R6Gf2z9i9JFcuyy0u79HfQsh/thifbYTF2ic82KJovKKkIB6Z9TdNhCXQ=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-qq7jXtO1+UEtCmCeBBIRDrPFIVI4ilEQ97qgBGdwXAARrUqSn/L9fUrkb1XP/mvVtoVeR2bt/0L77xx53bPZ/Q=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-O6b8QesPbJCRshsNApsOIpzKt3ztG35gfX9tEf4arD7mwNinsoCKxkj8TgEE0YRjmjtO3r9FlJnT/ENd9EVefQ=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.8", "", { "os": "linux", "cpu": "x64" }, "sha512-32iEXX/pXwikshNOGnERAFwFSfiltmijMIAbUhnNyjFr3tmWmMJWQKU2vNcFX0DACSXJ3ZWcSkzNbaKTdngH6g=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.8", "", { "os": "linux", "cpu": "x64" }, "sha512-s+VSSD+TfZeMEsCaFaHTaY5YNj3Dri8rST09gMvYQKwPphacRG7wbuQ5ZJMIJXN/puxPcg/nU+ucvWguPpvBDg=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.8", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@emnapi/wasi-threads": "^1.0.2", "@napi-rs/wasm-runtime": "^0.2.10", "@tybys/wasm-util": "^0.9.0", "tslib": "^2.8.0" }, "cpu": "none" }, "sha512-CXBPVFkpDjM67sS1psWohZ6g/2/cd+cq56vPxK4JeawelxwK4YECgl9Y9TjkE2qfF+9/s1tHHJqrC4SS6cVvSg=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-7GmYk1n28teDHUjPlIx4Z6Z4hHEgvP5ZW2QS9ygnDAdI/myh3HTHjDqtSqgu1BpRoI4OiLx+fThAyA1JePoENA=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.8", "", { "os": "win32", "cpu": "x64" }, "sha512-fou+U20j+Jl0EHwK92spoWISON2OBnCazIc038Xj2TdweYV33ZRkS9nwqiUi2d/Wba5xg5UoHfvynnb/UB49cQ=="], + + "@tailwindcss/vite": ["@tailwindcss/vite@4.1.8", "", { "dependencies": { "@tailwindcss/node": "4.1.8", "@tailwindcss/oxide": "4.1.8", "tailwindcss": "4.1.8" }, "peerDependencies": { "vite": "^5.2.0 || ^6" } }, "sha512-CQ+I8yxNV5/6uGaJjiuymgw0kEQiNKRinYbZXPdx1fk5WgiyReG0VaUx/Xq6aVNSUNJFzxm6o8FNKS5aMaim5A=="], + + "@threejs-kit/instanced-sprite-mesh": ["@threejs-kit/instanced-sprite-mesh@2.5.1", "", { "dependencies": { "diet-sprite": "^0.0.1", "earcut": "^2.2.4", "maath": "^0.10.7", "three-instanced-uniforms-mesh": "^0.52.4", "troika-three-utils": "^0.52.4" }, "peerDependencies": { "three": ">=0.170.0" } }, "sha512-pmt1ALRhbHhCJQTj2FuthH6PeLIeaM4hOuS2JO3kWSwlnvx/9xuUkjFR3JOi/myMqsH7pSsLIROSaBxDfttjeA=="], + + "@threlte/core": ["@threlte/core@8.0.4", "", { "dependencies": { "mitt": "^3.0.1" }, "peerDependencies": { "svelte": ">=5", "three": ">=0.155" } }, "sha512-kg0nTq00RqgTExIEKkyO/yCupCt73lybuFZObKHCRaY7dPcCvRD4tHkyKIFeYLYV4R6u1nqIuzfgDzYJqHbLlQ=="], + + "@threlte/extras": ["@threlte/extras@9.2.1", "", { "dependencies": { "@threejs-kit/instanced-sprite-mesh": "^2.5.1", "camera-controls": "^2.10.1", "three-mesh-bvh": "^0.9.0", "three-perf": "github:jerzakm/three-perf#three-kit-threlte-fork", "three-viewport-gizmo": "^2.2.0", "troika-three-text": "^0.52.4" }, "peerDependencies": { "svelte": ">=5", "three": ">=0.155" } }, "sha512-FzHGZadxtOIlD2ZAaH6m/vAiBVJWl/MWrW7O3DMl42ixYrGBR3FQwUbdxdHQR0akVqPESm6bwvwvRZlWqiOhAQ=="], + + "@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="], + + "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], + + "@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="], + + "@types/three": ["@types/three@0.176.0", "", { "dependencies": { "@dimforge/rapier3d-compat": "^0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": "*", "@webgpu/types": "*", "fflate": "~0.8.2", "meshoptimizer": "~0.18.1" } }, "sha512-FwfPXxCqOtP7EdYMagCFePNKoG1AGBDUEVKtluv2BTVRpSt7b+X27xNsirPCTCqY1pGYsPUzaM3jgWP7dXSxlw=="], + + "@types/webxr": ["@types/webxr@0.5.22", "", {}, "sha512-Vr6Stjv5jPRqH690f5I5GLjVk8GSsoQSYJ2FVd/3jJF7KaqfwPi3ehfBS96mlQ2kPCwZaX6U0rG2+NGHBKkA/A=="], + + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.33.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.33.0", "@typescript-eslint/type-utils": "8.33.0", "@typescript-eslint/utils": "8.33.0", "@typescript-eslint/visitor-keys": "8.33.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.33.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-CACyQuqSHt7ma3Ns601xykeBK/rDeZa3w6IS6UtMQbixO5DWy+8TilKkviGDH6jtWCo8FGRKEK5cLLkPvEammQ=="], + + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.33.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.33.0", "@typescript-eslint/types": "8.33.0", "@typescript-eslint/typescript-estree": "8.33.0", "@typescript-eslint/visitor-keys": "8.33.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-JaehZvf6m0yqYp34+RVnihBAChkqeH+tqqhS0GuX1qgPpwLvmTPheKEs6OeCK6hVJgXZHJ2vbjnC9j119auStQ=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.33.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.33.0", "@typescript-eslint/types": "^8.33.0", "debug": "^4.3.4" } }, "sha512-d1hz0u9l6N+u/gcrk6s6gYdl7/+pp8yHheRTqP6X5hVDKALEaTn8WfGiit7G511yueBEL3OpOEpD+3/MBdoN+A=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.33.0", "", { "dependencies": { "@typescript-eslint/types": "8.33.0", "@typescript-eslint/visitor-keys": "8.33.0" } }, "sha512-LMi/oqrzpqxyO72ltP+dBSP6V0xiUb4saY7WLtxSfiNEBI8m321LLVFU9/QDJxjDQG9/tjSqKz/E3380TEqSTw=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.33.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-sTkETlbqhEoiFmGr1gsdq5HyVbSOF0145SYDJ/EQmXHtKViCaGvnyLqWFFHtEXoS0J1yU8Wyou2UGmgW88fEug=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.33.0", "", { "dependencies": { "@typescript-eslint/typescript-estree": "8.33.0", "@typescript-eslint/utils": "8.33.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-lScnHNCBqL1QayuSrWeqAL5GmqNdVUQAAMTaCwdYEdWfIrSrOGzyLGRCHXcCixa5NK6i5l0AfSO2oBSjCjf4XQ=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.33.0", "", {}, "sha512-DKuXOKpM5IDT1FA2g9x9x1Ug81YuKrzf4mYX8FAVSNu5Wo/LELHWQyM1pQaDkI42bX15PWl0vNPt1uGiIFUOpg=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.33.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.33.0", "@typescript-eslint/tsconfig-utils": "8.33.0", "@typescript-eslint/types": "8.33.0", "@typescript-eslint/visitor-keys": "8.33.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-vegY4FQoB6jL97Tu/lWRsAiUUp8qJTqzAmENH2k59SJhw0Th1oszb9Idq/FyyONLuNqT1OADJPXfyUNOR8SzAQ=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.33.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.33.0", "@typescript-eslint/types": "8.33.0", "@typescript-eslint/typescript-estree": "8.33.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-lPFuQaLA9aSNa7D5u2EpRiqdAUhzShwGg/nhpBlc4GR6kcTABttCuyjFs8BcEZ8VWrjCBof/bePhP3Q3fS+Yrw=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.33.0", "", { "dependencies": { "@typescript-eslint/types": "8.33.0", "eslint-visitor-keys": "^4.2.0" } }, "sha512-7RW7CMYoskiz5OOGAWjJFxgb7c5UNjTG292gYhWeOAcFmYCtVCSqjqSBj5zMhxbXo2JOW95YYrUWJfU0zrpaGQ=="], + + "@webgpu/types": ["@webgpu/types@0.1.61", "", {}, "sha512-w2HbBvH+qO19SB5pJOJFKs533CdZqxl3fcGonqL321VHkW7W/iBo6H8bjDy6pr/+pbMwIu5dnuaAxH7NxBqUrQ=="], + + "acorn": ["acorn@8.14.1", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], + + "brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "camera-controls": ["camera-controls@2.10.1", "", { "peerDependencies": { "three": ">=0.126.1" } }, "sha512-KnaKdcvkBJ1Irbrzl8XD6WtZltkRjp869Jx8c0ujs9K+9WD+1D7ryBsCiVqJYUqt6i/HR5FxT7RLASieUD+Q5w=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + + "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + + "debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + + "detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="], + + "devalue": ["devalue@5.1.1", "", {}, "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw=="], + + "diet-sprite": ["diet-sprite@0.0.1", "", {}, "sha512-zSHI2WDAn1wJqJYxcmjWfJv3Iw8oL9reQIbEyx2x2/EZ4/qmUTIo8/5qOCurnAcq61EwtJJaZ0XTy2NRYqpB5A=="], + + "earcut": ["earcut@2.2.4", "", {}, "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ=="], + + "enhanced-resolve": ["enhanced-resolve@5.18.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg=="], + + "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=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@9.28.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.20.0", "@eslint/config-helpers": "^0.2.1", "@eslint/core": "^0.14.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.28.0", "@eslint/plugin-kit": "^0.3.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.3.0", "eslint-visitor-keys": "^4.2.0", "espree": "^10.3.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-ocgh41VhRlf9+fVpe7QKzwLj9c92fDiqOj8Y3Sd4/ZmVA4Btx4PlUYPq4pp9JDyupkf1upbEXecxL2mwNV7jPQ=="], + + "eslint-config-prettier": ["eslint-config-prettier@10.1.5", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw=="], + + "eslint-plugin-svelte": ["eslint-plugin-svelte@3.9.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.6.1", "@jridgewell/sourcemap-codec": "^1.5.0", "esutils": "^2.0.3", "globals": "^16.0.0", "known-css-properties": "^0.36.0", "postcss": "^8.4.49", "postcss-load-config": "^3.1.4", "postcss-safe-parser": "^7.0.0", "semver": "^7.6.3", "svelte-eslint-parser": "^1.2.0" }, "peerDependencies": { "eslint": "^8.57.1 || ^9.0.0", "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-nvIUNyyPGbr5922Kd1p/jXe+FfNdVPXsxLyrrXpwfSbZZEFdAYva9O/gm2lObC/wXkQo/AUmQkAihfmNJYeCjA=="], + + "eslint-scope": ["eslint-scope@8.3.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@4.2.0", "", {}, "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw=="], + + "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], + + "espree": ["espree@10.3.0", "", { "dependencies": { "acorn": "^8.14.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.0" } }, "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg=="], + + "esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="], + + "esrap": ["esrap@1.4.6", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-F/D2mADJ9SHY3IwksD4DAXjTt7qt7GWUf3/8RhCNWmC/67tyb55dpimHmy7EplakFaflV0R/PC+fdSPqrRHAQw=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], + + "fdir": ["fdir@6.4.5", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-4BG7puHpVsIYxZUbiUE3RqGloLaSSwzYie5jvasC4LWuBWzZawynvYouhjbQKw2JuIGYdm0DzIxl8iVidKlUEw=="], + + "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], + + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "globals": ["globals@16.2.0", "", {}, "sha512-O+7l9tPdHCU320IigZZPj5zmRCFG9xHmx9cU8FqU2Rp+JN714seHV+2S9+JslCpY4gJwU2vOGox0wzgae/MCEg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jiti": ["jiti@2.4.2", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="], + + "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "known-css-properties": ["known-css-properties@0.36.0", "", {}, "sha512-A+9jP+IUmuQsNdsLdcg6Yt7voiMF/D4K83ew0OpJtpu+l34ef7LaohWV0Rc6KNvzw6ZDizkqfyB5JznZnzuKQA=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.1", "", { "os": "linux", "cpu": "arm" }, "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="], + + "lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="], + + "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + + "maath": ["maath@0.10.8", "", { "peerDependencies": { "@types/three": ">=0.134.0", "three": ">=0.134.0" } }, "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g=="], + + "magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="], + + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + + "meshoptimizer": ["meshoptimizer@0.18.1", "", {}, "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + + "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + + "minizlib": ["minizlib@3.0.2", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA=="], + + "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], + + "mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="], + + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], + + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="], + + "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=="], + + "postcss-load-config": ["postcss-load-config@3.1.4", "", { "dependencies": { "lilconfig": "^2.0.5", "yaml": "^1.10.2" }, "peerDependencies": { "postcss": ">=8.0.9", "ts-node": ">=9.0.0" }, "optionalPeers": ["postcss", "ts-node"] }, "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg=="], + + "postcss-safe-parser": ["postcss-safe-parser@7.0.1", "", { "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A=="], + + "postcss-scss": ["postcss-scss@4.0.9", "", { "peerDependencies": { "postcss": "^8.4.29" } }, "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A=="], + + "postcss-selector-parser": ["postcss-selector-parser@7.1.0", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "prettier": ["prettier@3.5.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw=="], + + "prettier-plugin-svelte": ["prettier-plugin-svelte@3.4.0", "", { "peerDependencies": { "prettier": "^3.0.0", "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" } }, "sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ=="], + + "prettier-plugin-tailwindcss": ["prettier-plugin-tailwindcss@0.6.12", "", { "peerDependencies": { "@ianvs/prettier-plugin-sort-imports": "*", "@prettier/plugin-pug": "*", "@shopify/prettier-plugin-liquid": "*", "@trivago/prettier-plugin-sort-imports": "*", "@zackad/prettier-plugin-twig": "*", "prettier": "^3.0", "prettier-plugin-astro": "*", "prettier-plugin-css-order": "*", "prettier-plugin-import-sort": "*", "prettier-plugin-jsdoc": "*", "prettier-plugin-marko": "*", "prettier-plugin-multiline-arrays": "*", "prettier-plugin-organize-attributes": "*", "prettier-plugin-organize-imports": "*", "prettier-plugin-sort-imports": "*", "prettier-plugin-style-order": "*", "prettier-plugin-svelte": "*" }, "optionalPeers": ["@ianvs/prettier-plugin-sort-imports", "@prettier/plugin-pug", "@shopify/prettier-plugin-liquid", "@trivago/prettier-plugin-sort-imports", "@zackad/prettier-plugin-twig", "prettier-plugin-astro", "prettier-plugin-css-order", "prettier-plugin-import-sort", "prettier-plugin-jsdoc", "prettier-plugin-marko", "prettier-plugin-multiline-arrays", "prettier-plugin-organize-attributes", "prettier-plugin-organize-imports", "prettier-plugin-sort-imports", "prettier-plugin-style-order", "prettier-plugin-svelte"] }, "sha512-OuTQKoqNwV7RnxTPwXWzOFXy6Jc4z8oeRZYGuMpRyG3WbuR3jjXdQFK8qFBMBx8UHWdHrddARz2fgUenild6aw=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "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=="], + + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + + "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], + + "semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + + "set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "sirv": ["sirv@3.0.1", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "svelte": ["svelte@5.33.10", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "esm-env": "^1.2.1", "esrap": "^1.4.6", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-/yArPQIBoQS2p86LKnvJywOXkVHeEXnFgrDPSxkEfIAEkykopYuy2bF6UUqHG4IbZlJD6OurLxJT8Kn7kTk9WA=="], + + "svelte-check": ["svelte-check@4.2.1", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-e49SU1RStvQhoipkQ/aonDhHnG3qxHSBtNfBRb9pxVXoa+N7qybAo32KgA9wEb2PCYFNaDg7bZCdhLD1vHpdYA=="], + + "svelte-eslint-parser": ["svelte-eslint-parser@1.2.0", "", { "dependencies": { "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.0.0", "espree": "^10.0.0", "postcss": "^8.4.49", "postcss-scss": "^4.0.9", "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-mbPtajIeuiyU80BEyGvwAktBeTX7KCr5/0l+uRGLq1dafwRNrjfM5kHGJScEBlPG3ipu6dJqfW/k0/fujvIEVw=="], + + "tailwind-merge": ["tailwind-merge@3.3.0", "", {}, "sha512-fyW/pEfcQSiigd5SNn0nApUOxx0zB/dm6UDU/rEwc2c3sX2smWUNbapHv+QRqLGVp9GWX3THIa7MUGPo+YkDzQ=="], + + "tailwindcss": ["tailwindcss@4.1.8", "", {}, "sha512-kjeW8gjdxasbmFKpVGrGd5T4i40mV5J2Rasw48QARfYeQ8YS9x02ON9SFWax3Qf616rt4Cp3nVNIj6Hd1mP3og=="], + + "tapable": ["tapable@2.2.2", "", {}, "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg=="], + + "tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="], + + "three": ["three@0.177.0", "", {}, "sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg=="], + + "three-instanced-uniforms-mesh": ["three-instanced-uniforms-mesh@0.52.4", "", { "dependencies": { "troika-three-utils": "^0.52.4" }, "peerDependencies": { "three": ">=0.125.0" } }, "sha512-YwDBy05hfKZQtU+Rp0KyDf9yH4GxfhxMbVt9OYruxdgLfPwmDG5oAbGoW0DrKtKZSM3BfFcCiejiOHCjFBTeng=="], + + "three-mesh-bvh": ["three-mesh-bvh@0.9.0", "", { "peerDependencies": { "three": ">= 0.159.0" } }, "sha512-xAwZj0hZknpwVsdK5BBJTIAZDjDPZCRzURY1o+z/JHBON/jc2UetK1CzPeQZiiOVSfI4jV2z7sXnnGtgsgnjaA=="], + + "three-perf": ["three-perf@github:jerzakm/three-perf#322d7d3", { "dependencies": { "troika-three-text": "^0.52.0", "tweakpane": "^3.1.10" }, "peerDependencies": { "three": ">=0.170" } }, "jerzakm-three-perf-322d7d3"], + + "three-viewport-gizmo": ["three-viewport-gizmo@2.2.0", "", { "peerDependencies": { "three": ">=0.162.0 <1.0.0" } }, "sha512-Jo9Liur1rUmdKk75FZumLU/+hbF+RtJHi1qsKZpntjKlCYScK6tjbYoqvJ9M+IJphrlQJF5oReFW7Sambh0N4Q=="], + + "tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + + "troika-three-text": ["troika-three-text@0.52.4", "", { "dependencies": { "bidi-js": "^1.0.2", "troika-three-utils": "^0.52.4", "troika-worker-utils": "^0.52.0", "webgl-sdf-generator": "1.1.1" }, "peerDependencies": { "three": ">=0.125.0" } }, "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg=="], + + "troika-three-utils": ["troika-three-utils@0.52.4", "", { "peerDependencies": { "three": ">=0.125.0" } }, "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A=="], + + "troika-worker-utils": ["troika-worker-utils@0.52.0", "", {}, "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw=="], + + "ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="], + + "tweakpane": ["tweakpane@3.1.10", "", {}, "sha512-rqwnl/pUa7+inhI2E9ayGTqqP0EPOOn/wVvSWjZsRbZUItzNShny7pzwL3hVlaN4m9t/aZhsP0aFQ9U5VVR2VQ=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + + "typescript-eslint": ["typescript-eslint@8.33.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.33.0", "@typescript-eslint/parser": "8.33.0", "@typescript-eslint/utils": "8.33.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-5YmNhF24ylCsvdNW2oJwMzTbaeO4bg90KeGtMjUw0AGtHksgEPLRTUil+coHwCfiu4QjVJFnjp94DmU6zV7DhQ=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "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=="], + + "vitefu": ["vitefu@1.0.6", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" }, "optionalPeers": ["vite"] }, "sha512-+Rex1GlappUyNN6UfwbVZne/9cYC4+R2XDk9xkNXBKMw6HQagdX9PgZ8V2v1WUSK1wfBLp7qbI1+XSNIlB1xmA=="], + + "webgl-sdf-generator": ["webgl-sdf-generator@1.1.1", "", {}, "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + + "yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "zimmerframe": ["zimmerframe@1.1.2", "", {}, "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w=="], + + "zod": ["zod@3.25.42", "", {}, "sha512-PcALTLskaucbeHc41tU/xfjfhcz8z0GdhhDcSgrCTmSazUuqnYqiXO63M0QUBVwpBlsLsNVn5qHSC5Dw3KZvaQ=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + + "@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.4.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.4.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.0.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.10", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.9.0" }, "bundled": true }, "sha512-bCsCyeZEwVErsGmyPNSzwfwFn4OdxBj0mmv6hOFucB/k81Ojdu68RbZdxYsRQUPc9l6SU5F/cG+bXgWs3oUgsQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.9.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..722164567b8a56bb9ae83aa91f07c5ccfe44f26a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +version: '3.8' + +services: + lerobot-arena: + build: + context: . + dockerfile: Dockerfile + ports: + - "8080:8080" # Python backend + - "7860:7860" # Svelte frontend (HF Spaces default) + environment: + - NODE_ENV=production + - PYTHONUNBUFFERED=1 + restart: unless-stopped \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000000000000000000000000000000000000..ef07d3224241ae61aad6e07504d2f794d8970652 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,36 @@ +import prettier from 'eslint-config-prettier'; +import js from '@eslint/js'; +import { includeIgnoreFile } from '@eslint/compat'; +import svelte from 'eslint-plugin-svelte'; +import globals from 'globals'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript-eslint'; +import svelteConfig from './svelte.config.js'; + +const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url)); + +export default ts.config( + includeIgnoreFile(gitignorePath), + js.configs.recommended, + ...ts.configs.recommended, + ...svelte.configs.recommended, + prettier, + ...svelte.configs.prettier, + { + languageOptions: { + globals: { ...globals.browser, ...globals.node } + }, + rules: { 'no-undef': 'off' } + }, + { + files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'], + languageOptions: { + parserOptions: { + projectService: true, + extraFileExtensions: ['.svelte'], + parser: ts.parser, + svelteConfig + } + } + } +); diff --git a/package.json b/package.json new file mode 100644 index 0000000000000000000000000000000000000000..97c14f95305b8babc465dbf89f106d761a6a3291 --- /dev/null +++ b/package.json @@ -0,0 +1,47 @@ +{ + "name": "my-app", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "format": "prettier --write .", + "lint": "prettier --check . && eslint ." + }, + "devDependencies": { + "@eslint/compat": "^1.2.5", + "@eslint/js": "^9.18.0", + "@sveltejs/adapter-auto": "^6.0.0", + "@sveltejs/adapter-static": "^3.0.8", + "@sveltejs/kit": "^2.16.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@tailwindcss/vite": "^4.0.0", + "eslint": "^9.18.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-svelte": "^3.0.0", + "globals": "^16.0.0", + "prettier": "^3.4.2", + "prettier-plugin-svelte": "^3.3.3", + "prettier-plugin-tailwindcss": "^0.6.11", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.0.0", + "typescript-eslint": "^8.20.0", + "vite": "^6.2.6" + }, + "dependencies": { + "@threlte/core": "^8.0.4", + "@threlte/extras": "^9.2.1", + "@types/three": "^0.176.0", + "clsx": "^2.1.1", + "tailwind-merge": "^3.3.0", + "three": "^0.177.0", + "zod": "^3.25.42" + } +} diff --git a/src-python/.gitignore b/src-python/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..505a3b1ca2716907d5b292ab90c36df043c42bb6 --- /dev/null +++ b/src-python/.gitignore @@ -0,0 +1,10 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv diff --git a/src-python/.python-version b/src-python/.python-version new file mode 100644 index 0000000000000000000000000000000000000000..e4fba2183587225f216eeada4c78dfab6b2e65f5 --- /dev/null +++ b/src-python/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/src-python/README.md b/src-python/README.md new file mode 100644 index 0000000000000000000000000000000000000000..49010791d01da0582c5c4bdbfa7e17fd1a02fd7c --- /dev/null +++ b/src-python/README.md @@ -0,0 +1,656 @@ +# ๐Ÿค– LeRobot Arena - Complete Robot Control System + +A comprehensive WebSocket-based robot control platform featuring **master-slave architecture**, real-time 3D visualization, and seamless integration between physical robots and digital twins. + +## ๐Ÿ›๏ธ Core Architecture + +### Master-Slave Pattern +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” Commands โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” Execution โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ ๐ŸŽฎ MASTERS โ”‚ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บโ”‚ ๐Ÿค– ROBOT CORE โ”‚ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บโ”‚ ๐Ÿ”ง SLAVES โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ€ข Remote Server โ”‚ WebSocket โ”‚ โ€ข Robot Manager โ”‚ USB/Mock/WS โ”‚ โ€ข Physical Bot โ”‚ +โ”‚ โ€ข Demo Scripts โ”‚ HTTP API โ”‚ โ€ข State Sync โ”‚ Direct I/O โ”‚ โ€ข 3D Simulation โ”‚ +โ”‚ โ€ข Manual UI โ”‚ Sequences โ”‚ โ€ข Safety Logic โ”‚ Commands โ”‚ โ€ข Mock Testing โ”‚ +โ”‚ โ€ข AI Agents โ”‚ โ”‚ โ€ข Multi-Robot โ”‚ โ”‚ โ€ข Remote Proxy โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +**Key Principles:** +- ๐ŸŽฏ **Single Source of Truth**: Only one master per robot at a time +- ๐Ÿ”’ **Safety First**: Manual control disabled when master active +- ๐ŸŒ **Multi-Modal**: Same commands work across all slave types +- ๐Ÿ”„ **Real-Time Sync**: Virtual and physical states synchronized +- ๐Ÿ“ก **Network Native**: Built for remote operation from day one + +## ๐Ÿš€ Quick Start Guide + +### 1. Server Setup +```bash +# Install dependencies +cd src-python +uv sync + +# Start the WebSocket server +python start_server.py +# Server runs on http://localhost:8080 +``` + +### 2. Frontend Integration +```bash +# In your Svelte app +npm run dev +# Visit http://localhost:5173 +``` + +### 3. Create & Control Robot +```javascript +// Create robot in UI or via API +const robot = await robotManager.createRobot('my-robot', robotUrdfConfig); + +// Option 1: Manual control (sliders in UI) +await robot.updateJointValue('Rotation', 45); + +// Option 2: Demo sequences +await robotManager.connectDemoSequences('my-robot'); + +// Option 3: Remote server control +await robotManager.connectMaster('my-robot', { + type: "remote-server", + url: "ws://localhost:8080" +}); + +// Option 4: Direct API sequence trigger +curl -X POST http://localhost:8080/api/robots/my-robot/play-sequence/gentle-wave +``` + +## ๐ŸŽฎ Master Drivers (Command Sources) + +### MockSequenceMaster +**Pre-programmed movement patterns for testing and demos** + +```typescript +const config: MasterDriverConfig = { + type: "mock-sequence", + sequences: DEMO_SEQUENCES, + autoStart: true, + loopMode: true +}; + +await robotManager.connectMaster('robot-1', config); +``` + +**Available Sequences:** +- **๐ŸŒŠ Gentle Wave Pattern** (6s): Smooth greeting gesture with wrist movements +- **๐Ÿ” Small Scanning Pattern** (8s): Horizontal sweep for environment scanning +- **๐Ÿ’ช Tiny Flex Pattern** (8s): Articulation demonstration with elbow/jaw coordination + +**Features:** +- โœ… Safe movement ranges (ยฑ25ยฐ max) +- โœ… Smooth interpolation between keyframes +- โœ… Loop mode for continuous operation +- โœ… Automatic master takeover + +### RemoteServerMaster +**WebSocket connection to external control systems** + +```typescript +const config: MasterDriverConfig = { + type: "remote-server", + url: "ws://localhost:8080", + apiKey: "optional-auth-token" +}; + +await robotManager.connectMaster('robot-1', config); +``` + +**Features:** +- โœ… Real-time bidirectional communication +- โœ… Automatic reconnection with exponential backoff +- โœ… Heartbeat monitoring (30s intervals) +- โœ… Command acknowledgment +- โœ… Status and error reporting + +**Message Format:** +```json +{ + "type": "command", + "timestamp": "2024-01-01T12:00:00Z", + "data": { + "timestamp": 1704110400000, + "joints": [ + {"name": "Rotation", "value": 45, "speed": 100} + ] + } +} +``` + +## ๐Ÿ”ง Slave Drivers (Execution Targets) + +### MockSlave +**Perfect simulation for development and testing** + +```typescript +const config: SlaveDriverConfig = { + type: "mock-slave", + simulateLatency: 50, // Realistic response delay + simulateErrors: false, // Random connection issues + responseDelay: 20 // Command execution time +}; + +await robotManager.connectSlave('robot-1', config); +``` + +**Features:** +- โœ… Perfect command execution (real_value = virtual_value) +- โœ… Configurable network latency simulation +- โœ… Error injection for robustness testing +- โœ… Instant connection without hardware +- โœ… Real-time state feedback + +### USBSlave +**Direct serial communication with physical robots** + +```typescript +const config: SlaveDriverConfig = { + type: "usb-slave", + port: "/dev/ttyUSB0", // Auto-detect if undefined + baudRate: 115200 +}; + +await robotManager.connectSlave('robot-1', config); +``` + +**Features:** +- โœ… Feetech servo protocol support +- โœ… Position and speed control +- โœ… Real servo position feedback +- โœ… Calibration workflow for sync +- โœ… Error handling and recovery + +**Calibration Process:** +1. Manually position robot to match digital twin +2. Click "Calibrate" to sync virtual/real coordinates +3. System calculates offset: `real_pos = raw_pos + offset` +4. All future commands automatically compensated + +### WebSocketSlave +**Remote robot control via WebSocket relay** + +```typescript +const config: SlaveDriverConfig = { + type: "websocket-slave", + url: "ws://robot-proxy:8080", + robotId: "remote-arm-1" +}; + +await robotManager.connectSlave('robot-1', config); +``` + +**Use Cases:** +- ๐ŸŒ Control robots across internet +- ๐Ÿข Enterprise robot fleet management +- ๐Ÿ”’ Firewall-friendly robot access +- ๐Ÿ“ก Proxy through edge devices + +## ๐Ÿ“ก WebSocket Protocol Specification + +### Master โ†’ Server Communication + +#### Send Joint Command +```json +{ + "type": "command", + "timestamp": "2024-01-01T12:00:00Z", + "data": { + "timestamp": 1704110400000, + "joints": [ + {"name": "Rotation", "value": 45, "speed": 100}, + {"name": "Pitch", "value": -30, "speed": 150} + ], + "duration": 2000, + "metadata": {"source": "manual_control"} + } +} +``` + +#### Send Movement Sequence +```json +{ + "type": "sequence", + "timestamp": "2024-01-01T12:00:00Z", + "data": { + "id": "custom-dance", + "name": "Custom Dance Sequence", + "commands": [ + { + "timestamp": 0, + "joints": [{"name": "Rotation", "value": -30}], + "duration": 1000 + }, + { + "timestamp": 1000, + "joints": [{"name": "Rotation", "value": 30}], + "duration": 1000 + } + ], + "total_duration": 2000, + "loop": false + } +} +``` + +#### Heartbeat +```json +{ + "type": "heartbeat", + "timestamp": "2024-01-01T12:00:00Z" +} +``` + +### Slave โ†’ Server Communication + +#### Status Update +```json +{ + "type": "status_update", + "timestamp": "2024-01-01T12:00:00Z", + "data": { + "isConnected": true, + "lastConnected": "2024-01-01T11:58:00Z", + "error": null + } +} +``` + +#### Joint State Feedback +```json +{ + "type": "joint_states", + "timestamp": "2024-01-01T12:00:00Z", + "data": [ + { + "name": "Rotation", + "servo_id": 1, + "type": "revolute", + "virtual_value": 45.0, + "real_value": 44.8, + "limits": { + "lower": -180, + "upper": 180, + "velocity": 200 + } + } + ] +} +``` + +#### Error Reporting +```json +{ + "type": "error", + "timestamp": "2024-01-01T12:00:00Z", + "data": { + "code": "SERVO_TIMEOUT", + "message": "Servo 3 not responding", + "joint": "Elbow", + "severity": "warning" + } +} +``` + +## ๐Ÿ› ๏ธ REST API Reference + +### Robot Management + +| Method | Endpoint | Description | Example | +|--------|----------|-------------|---------| +| GET | `/` | Server status & metrics | `curl http://localhost:8080/` | +| GET | `/api/robots` | List all robots | `curl http://localhost:8080/api/robots` | +| POST | `/api/robots` | Create new robot | `curl -X POST -H "Content-Type: application/json" -d '{"robot_type":"so-arm100","name":"My Robot"}' http://localhost:8080/api/robots` | +| GET | `/api/robots/{id}` | Get robot details | `curl http://localhost:8080/api/robots/robot-123` | +| GET | `/api/robots/{id}/status` | Get connection status | `curl http://localhost:8080/api/robots/robot-123/status` | +| DELETE | `/api/robots/{id}` | Delete robot | `curl -X DELETE http://localhost:8080/api/robots/robot-123` | + +### Sequence Control + +| Method | Endpoint | Description | Example | +|--------|----------|-------------|---------| +| GET | `/api/sequences` | List demo sequences | `curl http://localhost:8080/api/sequences` | +| POST | `/api/robots/{id}/play-sequence/{seq_id}` | Play sequence | `curl -X POST http://localhost:8080/api/robots/robot-123/play-sequence/gentle-wave` | +| POST | `/api/robots/{id}/stop-sequence` | Stop sequences | `curl -X POST http://localhost:8080/api/robots/robot-123/stop-sequence` | + +### WebSocket Endpoints + +| Endpoint | Purpose | Client Type | Example | +|----------|---------|-------------|---------| +| `/ws/master/{robot_id}` | Send commands | Control sources | `ws://localhost:8080/ws/master/robot-123` | +| `/ws/slave/{robot_id}` | Receive commands | Execution targets | `ws://localhost:8080/ws/slave/robot-123` | + +## ๐ŸŽฏ Usage Scenarios + +### 1. ๐Ÿงช Development & Testing +```bash +# Create robot with mock slave for safe testing +robot = await robotManager.createRobot('test-bot', urdfConfig); +await robotManager.connectMockSlave('test-bot', 50); +await robotManager.connectDemoSequences('test-bot'); +``` + +**Perfect for:** +- Algorithm development without hardware risk +- UI/UX testing with realistic feedback +- Automated testing pipelines +- Demo presentations + +### 2. ๐Ÿฆพ Physical Robot Control +```bash +# Connect real hardware +robot = await robotManager.createRobot('real-bot', urdfConfig); +await robotManager.connectUSBSlave('real-bot', '/dev/ttyUSB0'); + +# Manual control via UI sliders +# OR automated via demo sequences +await robotManager.connectDemoSequences('real-bot'); +``` + +**Calibration Workflow:** +1. Connect USB slave to robot +2. Manually position to match 3D model rest pose +3. Click "Calibrate" to sync coordinate systems +4. Robot now mirrors 3D model movements precisely + +### 3. ๐ŸŒ Remote Operation +```bash +# Master controls slave over internet +# Master side: +await robotManager.connectMaster('local-avatar', { + type: "remote-server", + url: "wss://robot-farm.com:8080" +}); + +# Slave side (at robot location): +await robotManager.connectSlave('physical-robot', { + type: "websocket-slave", + url: "wss://robot-farm.com:8080", + robotId: "local-avatar" +}); +``` + +**Use Cases:** +- Telepresence robotics +- Remote maintenance and inspection +- Distributed manufacturing +- Educational robot sharing + +### 4. ๐Ÿค– Multi-Robot Coordination +```bash +# One master controlling multiple robots +await robotManager.connectMaster('fleet-commander', masterConfig); + +// Multiple slaves executing same commands +for (const robot of robotFleet) { + await robotManager.connectSlave(robot.id, slaveConfig); +} +``` + +**Applications:** +- Synchronized dance performances +- Assembly line coordination +- Swarm robotics research +- Entertainment shows + +### 5. ๐Ÿง  AI Agent Integration +```javascript +// AI agent as master driver +class AIAgentMaster implements MasterDriver { + async generateNextCommand(): Promise { + const sensorData = await this.readEnvironment(); + const decision = await this.aiModel.predict(sensorData); + return this.convertToRobotCommand(decision); + } +} + +await robot.setMaster(new AIAgentMaster(config)); +``` + +## ๐Ÿ”ง Integration Guide + +### Frontend Integration (Svelte) +```typescript +import { robotManager } from '$lib/robot/RobotManager.svelte'; +import { robotUrdfConfigMap } from '$lib/configs/robotUrdfConfig'; + +// Create robot +const robot = await robotManager.createRobot('my-robot', robotUrdfConfigMap['so-arm100']); + +// Add visualization +await robotManager.connectMockSlave('my-robot'); + +// Add control +await robotManager.connectDemoSequences('my-robot', true); + +// Monitor state +robot.joints.forEach(joint => { + console.log(`${joint.name}: virtual=${joint.virtualValue}ยฐ real=${joint.realValue}ยฐ`); +}); +``` + +### Backend Integration (Python) +```python +import asyncio +import websockets +import json + +async def robot_controller(): + uri = "ws://localhost:8080/ws/master/my-robot" + async with websockets.connect(uri) as websocket: + # Send command + command = { + "type": "command", + "timestamp": "2024-01-01T12:00:00Z", + "data": { + "timestamp": 1704110400000, + "joints": [{"name": "Rotation", "value": 45}] + } + } + await websocket.send(json.dumps(command)) + + # Listen for responses + async for message in websocket: + data = json.loads(message) + if data["type"] == "joint_states": + print(f"Robot position: {data['data']}") + +asyncio.run(robot_controller()) +``` + +### Hardware Integration (Arduino/C++) +```cpp +#include +#include +#include + +WebSocketsClient webSocket; + +void onWebSocketEvent(WStype_t type, uint8_t * payload, size_t length) { + if (type == WStype_TEXT) { + DynamicJsonDocument doc(1024); + deserializeJson(doc, payload); + + if (doc["type"] == "execute_command") { + JsonArray joints = doc["data"]["joints"]; + for (JsonObject joint : joints) { + String name = joint["name"]; + float value = joint["value"]; + moveServo(name, value); + } + } + } +} + +void setup() { + webSocket.begin("192.168.1.100", 8080, "/ws/slave/arduino-bot"); + webSocket.onEvent(onWebSocketEvent); +} +``` + +## ๐Ÿ›ก๏ธ Security & Production + +### Authentication +```typescript +// API key authentication (planned) +const master = new RemoteServerMaster({ + type: "remote-server", + url: "wss://secure-robot-farm.com:8080", + apiKey: "your-secret-api-key" +}, robotId); +``` + +### TLS/SSL +```bash +# Production deployment with SSL +uvicorn main:app --host 0.0.0.0 --port 443 --ssl-keyfile key.pem --ssl-certfile cert.pem +``` + +### Rate Limiting & Safety +```python +# Built-in protections +- Command rate limiting (100 commands/second max) +- Joint velocity limits from URDF +- Emergency stop on connection loss +- Position bounds checking +- Servo temperature monitoring +``` + +## ๐Ÿš€ Deployment Options + +### Development +```bash +cd src-python && python start_server.py +``` + +### Docker +```dockerfile +FROM python:3.12-slim +COPY src-python/ /app/ +WORKDIR /app +RUN pip install -r requirements.txt +EXPOSE 8080 +CMD ["python", "start_server.py"] +``` + +### Cloud (Railway/Heroku) +```bash +# Procfile +web: cd src-python && python start_server.py +``` + +### Raspberry Pi (Edge) +```bash +# systemd service for autostart +sudo systemctl enable lerobot-arena +sudo systemctl start lerobot-arena +``` + +## ๐Ÿงช Testing & Debugging + +### Unit Tests +```bash +cd src-python +pytest tests/ -v +``` + +### Integration Tests +```javascript +// Frontend testing +import { expect, test } from '@playwright/test'; + +test('robot creation and control', async ({ page }) => { + await page.goto('/'); + await page.click('[data-testid="create-robot"]'); + await page.click('[data-testid="connect-demo-sequences"]'); + await expect(page.locator('[data-testid="robot-status"]')).toContainText('Master + Slaves'); +}); +``` + +### Debug Mode +```bash +# Enable verbose logging +export LOG_LEVEL=DEBUG +python start_server.py + +# Frontend debug +export VITE_DEBUG=true +npm run dev +``` + +### Health Monitoring +```bash +# Check server health +curl http://localhost:8080/ + +# Monitor WebSocket connections +curl http://localhost:8080/api/robots +``` + +## ๐Ÿ”ฎ Roadmap + +### v2.0 - Enhanced Control +- [ ] **Script Player Master**: Execute Python/JS scripts +- [ ] **Simulation Slave**: Physics-based simulation +- [ ] **Force Control**: Torque and compliance modes +- [ ] **Vision Integration**: Camera feeds and computer vision + +### v2.1 - Enterprise Features +- [ ] **Authentication**: JWT tokens and user management +- [ ] **Multi-tenancy**: Isolated robot fleets per organization +- [ ] **Monitoring**: Prometheus metrics and Grafana dashboards +- [ ] **Recording**: Command sequences and replay + +### v2.2 - Advanced Robotics +- [ ] **Path Planning**: Trajectory optimization +- [ ] **Collision Detection**: Safety in shared workspaces +- [ ] **AI Integration**: Reinforcement learning environments +- [ ] **ROS Bridge**: Integration with ROS2 ecosystem + +## ๐Ÿค Contributing + +### Development Setup +```bash +# Frontend +npm install +npm run dev + +# Backend +cd src-python +uv sync +python start_server.py + +# Tests +npm run test +cd src-python && pytest +``` + +### Code Style +- **TypeScript**: ESLint + Prettier +- **Python**: Black + isort + mypy +- **Commits**: Conventional commits format + +### Pull Request Process +1. Fork repository +2. Create feature branch +3. Add tests for new functionality +4. Ensure all tests pass +5. Update documentation +6. Submit PR with clear description + +## ๐Ÿ“„ License + +MIT License - Feel free to use in commercial and personal projects. + +--- + +**Built with โค๏ธ for the robotics community** + +*LeRobot Arena bridges the gap between digital twins and physical robots, making robotics accessible to developers, researchers, and enthusiasts worldwide.* diff --git a/src-python/pyproject.toml b/src-python/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..f765c07ac51bf4935f85d117e8cdeaee03a53d0b --- /dev/null +++ b/src-python/pyproject.toml @@ -0,0 +1,43 @@ +[project] +name = "lerobot-arena-server" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.115.12", + "uvicorn>=0.34.3", + "websockets>=15.0.1", +] + + +[tool.hatch.build.targets.wheel] +packages = ["src"] + +[project.scripts] +lerobot-arena-server = "start_server:main" + +[project.optional-dependencies] +packaging = [ + "box-packager>=0.4.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[dependency-groups] +dev = [ + "box-packager>=0.4.0", +] + +[tool.box] +builder = "hatch" +is_gui = false +app_entry = "start_server:main" +entry_type = "spec" +python_version = "3.12" + +[tool.box.env-vars] +PYAPP_UV_ENABLED = "true" +PYAPP_EXPOSE_METADATA = "false" \ No newline at end of file diff --git a/src-python/src/__init__.py b/src-python/src/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..4da45add69993728aeaa2b6f6f17b86c4635daea --- /dev/null +++ b/src-python/src/__init__.py @@ -0,0 +1 @@ +# LeRobot Arena Server Package \ No newline at end of file diff --git a/src-python/src/connection_manager.py b/src-python/src/connection_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..6b28700f2eb823110a306634cff8a9ca416ff3d6 --- /dev/null +++ b/src-python/src/connection_manager.py @@ -0,0 +1,147 @@ +from fastapi import WebSocket +from typing import Dict, List, Optional +import logging + +logger = logging.getLogger(__name__) + +class ConnectionManager: + """Manages WebSocket connections for masters and slaves""" + + def __init__(self): + # robot_id -> websocket + self.master_connections: Dict[str, WebSocket] = {} + + # robot_id -> list of websockets + self.slave_connections: Dict[str, List[WebSocket]] = {} + + # connection_id -> (robot_id, websocket) + self.connection_registry: Dict[str, tuple] = {} + + async def connect_master(self, connection_id: str, robot_id: str, websocket: WebSocket): + """Connect a master to a robot""" + # Only one master per robot + if robot_id in self.master_connections: + logger.warning(f"Disconnecting existing master for robot {robot_id}") + await self.disconnect_master_by_robot(robot_id) + + self.master_connections[robot_id] = websocket + self.connection_registry[connection_id] = (robot_id, websocket) + logger.info(f"Master {connection_id} connected to robot {robot_id}") + + async def connect_slave(self, connection_id: str, robot_id: str, websocket: WebSocket): + """Connect a slave to a robot""" + if robot_id not in self.slave_connections: + self.slave_connections[robot_id] = [] + + self.slave_connections[robot_id].append(websocket) + self.connection_registry[connection_id] = (robot_id, websocket) + logger.info(f"Slave {connection_id} connected to robot {robot_id} ({len(self.slave_connections[robot_id])} total slaves)") + + async def disconnect_master(self, connection_id: str): + """Disconnect a master connection""" + if connection_id in self.connection_registry: + robot_id, websocket = self.connection_registry[connection_id] + + if robot_id in self.master_connections: + del self.master_connections[robot_id] + + del self.connection_registry[connection_id] + logger.info(f"Master {connection_id} disconnected from robot {robot_id}") + + async def disconnect_master_by_robot(self, robot_id: str): + """Disconnect master by robot ID""" + if robot_id in self.master_connections: + websocket = self.master_connections[robot_id] + + # Find and remove from connection registry + for conn_id, (r_id, ws) in list(self.connection_registry.items()): + if r_id == robot_id and ws == websocket: + del self.connection_registry[conn_id] + break + + del self.master_connections[robot_id] + + try: + await websocket.close() + except Exception as e: + logger.error(f"Error closing master websocket for robot {robot_id}: {e}") + + async def disconnect_slave(self, connection_id: str): + """Disconnect a slave connection""" + if connection_id in self.connection_registry: + robot_id, websocket = self.connection_registry[connection_id] + + if robot_id in self.slave_connections: + try: + self.slave_connections[robot_id].remove(websocket) + if not self.slave_connections[robot_id]: # Remove empty list + del self.slave_connections[robot_id] + except ValueError: + logger.warning(f"Slave websocket not found in connections for robot {robot_id}") + + del self.connection_registry[connection_id] + logger.info(f"Slave {connection_id} disconnected from robot {robot_id}") + + def get_master_connection(self, robot_id: str) -> Optional[WebSocket]: + """Get master connection for a robot""" + return self.master_connections.get(robot_id) + + def get_slave_connections(self, robot_id: str) -> List[WebSocket]: + """Get all slave connections for a robot""" + return self.slave_connections.get(robot_id, []) + + def get_connection_count(self) -> int: + """Get total number of active connections""" + master_count = len(self.master_connections) + slave_count = sum(len(slaves) for slaves in self.slave_connections.values()) + return master_count + slave_count + + def get_robot_connection_info(self, robot_id: str) -> dict: + """Get connection information for a robot""" + has_master = robot_id in self.master_connections + slave_count = len(self.slave_connections.get(robot_id, [])) + + return { + "robot_id": robot_id, + "has_master": has_master, + "slave_count": slave_count, + "total_connections": (1 if has_master else 0) + slave_count + } + + async def cleanup_robot_connections(self, robot_id: str): + """Clean up all connections for a robot""" + # Close master connection + if robot_id in self.master_connections: + try: + await self.master_connections[robot_id].close() + except Exception as e: + logger.error(f"Error closing master connection for robot {robot_id}: {e}") + del self.master_connections[robot_id] + + # Close slave connections + if robot_id in self.slave_connections: + for websocket in self.slave_connections[robot_id]: + try: + await websocket.close() + except Exception as e: + logger.error(f"Error closing slave connection for robot {robot_id}: {e}") + del self.slave_connections[robot_id] + + # Clean up connection registry + to_remove = [] + for conn_id, (r_id, _) in self.connection_registry.items(): + if r_id == robot_id: + to_remove.append(conn_id) + + for conn_id in to_remove: + del self.connection_registry[conn_id] + + logger.info(f"Cleaned up all connections for robot {robot_id}") + + def list_all_connections(self) -> dict: + """List all active connections for debugging""" + return { + "masters": {robot_id: "connected" for robot_id in self.master_connections.keys()}, + "slaves": {robot_id: len(slaves) for robot_id, slaves in self.slave_connections.items()}, + "total_connections": self.get_connection_count() + } \ No newline at end of file diff --git a/src-python/src/main.py b/src-python/src/main.py new file mode 100644 index 0000000000000000000000000000000000000000..ee3b46026f306a0a380fb561851beead19d1cc7a --- /dev/null +++ b/src-python/src/main.py @@ -0,0 +1,554 @@ +import asyncio +import logging +import uuid +from datetime import UTC, datetime + +from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect +from fastapi.middleware.cors import CORSMiddleware + +from .connection_manager import ConnectionManager +from .models import ( + CreateRobotRequest, + Robot, + RobotStatus, +) +from .robot_manager import RobotManager + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +app = FastAPI( + title="LeRobot Arena Server", + description="WebSocket-based robot control server for master-slave architecture", + version="1.0.0", +) + +# CORS middleware for web frontend +app.add_middleware( + CORSMiddleware, + allow_origins=[ + "http://localhost:5173", + "http://localhost:5174", + "http://localhost:3000", + ], # Add your frontend URLs + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Global managers +connection_manager = ConnectionManager() +robot_manager = RobotManager() + + +@app.on_event("startup") +async def startup_event(): + logger.info("๐Ÿค– LeRobot Arena Server starting up...") + + # Create some demo robots for testing + await robot_manager.create_robot("demo-arm-1", "so-arm100", "Demo Arm Robot 1") + await robot_manager.create_robot("demo-arm-2", "so-arm100", "Demo Arm Robot 2") + + logger.info("โœ… Server ready for robot connections!") + + +@app.get("/") +async def root(): + return { + "message": "LeRobot Arena Server", + "version": "1.0.0", + "robots_connected": len(robot_manager.robots), + "active_connections": connection_manager.get_connection_count(), + } + + +# ============= ROBOT MANAGEMENT API ============= + + +@app.get("/api/robots", response_model=list[Robot]) +async def list_robots(): + """Get list of all available robots""" + robots = list(robot_manager.robots.values()) + print("๐Ÿ” DEBUG: /api/robots called") + print(f"๐Ÿ” DEBUG: Found {len(robots)} robots") + for robot in robots: + print( + f"๐Ÿ” DEBUG: Robot - ID: {robot.id}, Name: {robot.name}, Type: {robot.robot_type}" + ) + return robots + + +@app.post("/api/robots", response_model=Robot) +async def create_robot(request: CreateRobotRequest): + """Create a new robot""" + robot_id = f"robot-{uuid.uuid4().hex[:8]}" + return await robot_manager.create_robot( + robot_id, request.robot_type, request.name or f"Robot {robot_id}" + ) + + +@app.get("/api/robots/{robot_id}", response_model=Robot) +async def get_robot(robot_id: str): + """Get robot details""" + robot = robot_manager.get_robot(robot_id) + if not robot: + raise HTTPException(status_code=404, detail="Robot not found") + return robot + + +@app.get("/api/robots/{robot_id}/status", response_model=RobotStatus) +async def get_robot_status(robot_id: str): + """Get robot connection status""" + status = robot_manager.get_robot_status(robot_id) + if not status: + raise HTTPException(status_code=404, detail="Robot not found") + return status + + +@app.delete("/api/robots/{robot_id}") +async def delete_robot(robot_id: str): + """Delete a robot""" + if not robot_manager.get_robot(robot_id): + raise HTTPException(status_code=404, detail="Robot not found") + + await robot_manager.delete_robot(robot_id) + return {"message": f"Robot {robot_id} deleted"} + + +# ============= DEMO SEQUENCE API ============= + + +@app.get("/api/sequences") +async def list_demo_sequences(): + """Get list of available demo sequences""" + from .models import DEMO_SEQUENCES + + return [ + { + "id": seq.id, + "name": seq.name, + "total_duration": seq.total_duration, + "command_count": len(seq.commands), + } + for seq in DEMO_SEQUENCES + ] + + +@app.post("/api/robots/{robot_id}/play-sequence/{sequence_id}") +async def play_demo_sequence(robot_id: str, sequence_id: str): + """Play a demo sequence on a robot""" + # Check if robot exists + robot = robot_manager.get_robot(robot_id) + if not robot: + raise HTTPException(status_code=404, detail="Robot not found") + + # Find the sequence + from .models import DEMO_SEQUENCES + + sequence = next((seq for seq in DEMO_SEQUENCES if seq.id == sequence_id), None) + if not sequence: + raise HTTPException(status_code=404, detail="Sequence not found") + + # Get available connections + slave_connections = connection_manager.get_slave_connections(robot_id) + master_connection = connection_manager.get_master_connection(robot_id) + + if not slave_connections and not master_connection: + raise HTTPException( + status_code=400, + detail="No connections available. Connect a master (for 3D visualization) or slave (for robot execution) to play sequences.", + ) + + # Send sequence to slaves and/or master + notifications_sent = 0 + try: + # Send to slaves if available (physical robots) + if slave_connections: + await broadcast_to_slaves( + robot_id, + { + "type": "execute_sequence", + "timestamp": datetime.now(UTC).isoformat(), + "data": sequence.model_dump(mode="json"), + }, + ) + notifications_sent += len(slave_connections) + logger.info( + f"๐Ÿค– Sent sequence '{sequence.name}' to {len(slave_connections)} slaves" + ) + + # Send to master if available (3D visualization) + if master_connection: + await broadcast_to_master( + robot_id, + { + "type": "play_sequence", + "timestamp": datetime.now(UTC).isoformat(), + "data": sequence.model_dump(mode="json"), + }, + ) + notifications_sent += 1 + logger.info( + f"๐ŸŽฌ Sent sequence '{sequence.name}' to master for visualization" + ) + + logger.info( + f"๐ŸŽฌ Playing sequence '{sequence.name}' on robot {robot_id} ({notifications_sent} connections)" + ) + + return { + "message": f"Sequence '{sequence.name}' started on robot {robot_id}", + "sequence": { + "id": sequence.id, + "name": sequence.name, + "duration": sequence.total_duration, + }, + "slaves_notified": len(slave_connections), + "master_notified": 1 if master_connection else 0, + "total_notifications": notifications_sent, + } + + except Exception as e: + logger.error(f"Failed to play sequence {sequence_id} on robot {robot_id}: {e}") + raise HTTPException(status_code=500, detail=f"Failed to play sequence: {e}") + + +@app.post("/api/robots/{robot_id}/stop-sequence") +async def stop_sequence(robot_id: str): + """Stop any running sequence on a robot""" + # Check if robot exists + robot = robot_manager.get_robot(robot_id) + if not robot: + raise HTTPException(status_code=404, detail="Robot not found") + + # Check if robot has slaves + slave_connections = connection_manager.get_slave_connections(robot_id) + if not slave_connections: + raise HTTPException(status_code=400, detail="No slave connections available") + + try: + # Send stop command to all slaves + await broadcast_to_slaves( + robot_id, + { + "type": "stop_sequence", + "timestamp": datetime.now(UTC).isoformat(), + "data": {}, + }, + ) + + logger.info(f"โน๏ธ Stopped sequences on robot {robot_id}") + + return { + "message": f"Sequences stopped on robot {robot_id}", + "slaves_notified": len(slave_connections), + } + + except Exception as e: + logger.error(f"Failed to stop sequences on robot {robot_id}: {e}") + raise HTTPException(status_code=500, detail=f"Failed to stop sequences: {e}") + + +# ============= WEBSOCKET ENDPOINTS ============= + + +@app.websocket("/ws/master/{robot_id}") +async def websocket_master_endpoint(websocket: WebSocket, robot_id: str): + """WebSocket endpoint for master connections (command sources)""" + await websocket.accept() + + robot = robot_manager.get_robot(robot_id) + if not robot: + # Auto-create robot if it doesn't exist + logger.info(f"๐Ÿค– Auto-creating robot {robot_id} for master connection") + try: + robot = await robot_manager.create_robot( + robot_id, "so-arm100", f"Auto-created Robot {robot_id}" + ) + except Exception as e: + logger.error(f"Failed to auto-create robot {robot_id}: {e}") + await websocket.close(code=4003, reason="Failed to create robot") + return + + connection_id = f"master-{uuid.uuid4().hex[:8]}" + logger.info(f"๐ŸŽฎ Master connected: {connection_id} for robot {robot_id}") + + try: + # Register master connection + await connection_manager.connect_master(connection_id, robot_id, websocket) + await robot_manager.set_master_connected(robot_id, connection_id) + + # Send initial robot state + await websocket.send_json({ + "type": "robot_state", + "timestamp": datetime.now(UTC).isoformat(), + "data": robot.model_dump(mode="json"), + }) + + # Handle incoming messages + async for message in websocket.iter_json(): + await handle_master_message(connection_id, robot_id, message) + + except WebSocketDisconnect: + logger.info(f"๐Ÿ”Œ Master disconnected: {connection_id}") + except Exception as e: + logger.error(f"โŒ Master connection error: {e}") + finally: + await connection_manager.disconnect_master(connection_id) + await robot_manager.set_master_disconnected(robot_id) + + +@app.websocket("/ws/slave/{robot_id}") +async def websocket_slave_endpoint(websocket: WebSocket, robot_id: str): + """WebSocket endpoint for slave connections (execution targets)""" + print(f"๐Ÿ” DEBUG: Slave WebSocket connection attempt for robot {robot_id}") + await websocket.accept() + + robot = robot_manager.get_robot(robot_id) + if not robot: + # Auto-create robot if it doesn't exist + print(f"๐Ÿ” DEBUG: Robot {robot_id} not found, auto-creating...") + logger.info(f"๐Ÿค– Auto-creating robot {robot_id} for slave connection") + try: + robot = await robot_manager.create_robot( + robot_id, "so-arm100", f"Auto-created Robot {robot_id}" + ) + print(f"๐Ÿ” DEBUG: Successfully auto-created robot {robot_id}") + except Exception as e: + print(f"๐Ÿ” DEBUG: Failed to auto-create robot {robot_id}: {e}") + logger.error(f"Failed to auto-create robot {robot_id}: {e}") + await websocket.close(code=4003, reason="Failed to create robot") + return + else: + print(f"๐Ÿ” DEBUG: Robot {robot_id} found, proceeding with connection") + + connection_id = f"slave-{uuid.uuid4().hex[:8]}" + print(f"๐Ÿ” DEBUG: Generated slave connection ID: {connection_id}") + logger.info(f"๐Ÿค– Slave connected: {connection_id} for robot {robot_id}") + + try: + # Register slave connection + await connection_manager.connect_slave(connection_id, robot_id, websocket) + await robot_manager.add_slave_connection(robot_id, connection_id) + print(f"๐Ÿ” DEBUG: Slave {connection_id} registered successfully") + + # Send initial commands if any + await sync_slave_with_current_state(robot_id, websocket) + print(f"๐Ÿ” DEBUG: Initial state sync sent to slave {connection_id}") + + # Handle incoming messages (mainly status updates) + async for message in websocket.iter_json(): + print(f"๐Ÿ” DEBUG: Received message from slave {connection_id}: {message}") + await handle_slave_message(connection_id, robot_id, message) + + except WebSocketDisconnect: + print(f"๐Ÿ” DEBUG: Slave {connection_id} disconnected normally") + logger.info(f"๐Ÿ”Œ Slave disconnected: {connection_id}") + except Exception as e: + print(f"๐Ÿ” DEBUG: Slave {connection_id} connection error: {e}") + logger.error(f"โŒ Slave connection error: {e}") + finally: + print(f"๐Ÿ” DEBUG: Cleaning up slave {connection_id}") + await connection_manager.disconnect_slave(connection_id) + await robot_manager.remove_slave_connection(robot_id, connection_id) + + +# ============= MESSAGE HANDLERS ============= + + +async def handle_master_message(connection_id: str, robot_id: str, message: dict): + """Handle incoming messages from master connections""" + print(f"๐Ÿ” DEBUG: Received message from master {connection_id}: {message}") + try: + msg_type = message.get("type") + print(f"๐Ÿ” DEBUG: Message type: {msg_type}") + + if msg_type == "command": + print("๐Ÿ” DEBUG: Processing command message") + # Forward command to all slaves + await broadcast_to_slaves( + robot_id, + { + "type": "execute_command", + "timestamp": datetime.now(UTC).isoformat(), + "data": message.get("data"), + }, + ) + + elif msg_type == "sequence": + print("๐Ÿ” DEBUG: Processing sequence message") + # Forward sequence to all slaves + await broadcast_to_slaves( + robot_id, + { + "type": "execute_sequence", + "timestamp": datetime.now(UTC).isoformat(), + "data": message.get("data"), + }, + ) + + elif msg_type == "start_control": + print("๐Ÿ” DEBUG: Processing start_control message") + # Handle start control message (acknowledge it) + master_ws = connection_manager.get_master_connection(robot_id) + if master_ws: + await master_ws.send_json({ + "type": "control_started", + "timestamp": datetime.now(UTC).isoformat(), + "data": {"robot_id": robot_id}, + }) + print("๐Ÿ” DEBUG: Sent control_started acknowledgment") + + elif msg_type == "stop_control": + print("๐Ÿ” DEBUG: Processing stop_control message") + # Handle stop control message + + elif msg_type == "pause_control": + print("๐Ÿ” DEBUG: Processing pause_control message") + # Handle pause control message + + elif msg_type == "resume_control": + print("๐Ÿ” DEBUG: Processing resume_control message") + # Handle resume control message + + elif msg_type == "heartbeat": + print("๐Ÿ” DEBUG: Processing heartbeat message") + # Respond to heartbeat + master_ws = connection_manager.get_master_connection(robot_id) + if master_ws: + await master_ws.send_json({ + "type": "heartbeat_ack", + "timestamp": datetime.now(UTC).isoformat(), + }) + print("๐Ÿ” DEBUG: Sent heartbeat_ack") + + else: + print(f"๐Ÿ” DEBUG: Unknown message type: {msg_type}") + logger.warning( + f"Unknown message type from master {connection_id}: {msg_type}" + ) + + except Exception as e: + print(f"๐Ÿ” DEBUG: Error handling master message: {e}") + logger.error(f"Error handling master message: {e}") + + +async def handle_slave_message(connection_id: str, robot_id: str, message: dict): + """Handle incoming messages from slave connections""" + print(f"๐Ÿ” DEBUG: Processing slave message from {connection_id}: {message}") + try: + msg_type = message.get("type") + print(f"๐Ÿ” DEBUG: Slave message type: {msg_type}") + + if msg_type == "status_update": + print("๐Ÿ” DEBUG: Processing slave status_update") + # Forward status to master + await broadcast_to_master( + robot_id, + { + "type": "slave_status", + "timestamp": datetime.now(UTC).isoformat(), + "slave_id": connection_id, + "data": message.get("data"), + }, + ) + + elif msg_type == "joint_states": + print("๐Ÿ” DEBUG: Processing slave joint_states") + # Forward joint states to master + await broadcast_to_master( + robot_id, + { + "type": "joint_states", + "timestamp": datetime.now(UTC).isoformat(), + "slave_id": connection_id, + "data": message.get("data"), + }, + ) + + elif msg_type == "error": + print("๐Ÿ” DEBUG: Processing slave error") + # Forward error to master + await broadcast_to_master( + robot_id, + { + "type": "slave_error", + "timestamp": datetime.now(UTC).isoformat(), + "slave_id": connection_id, + "data": message.get("data"), + }, + ) + + else: + print(f"๐Ÿ” DEBUG: Unknown slave message type: {msg_type}") + logger.warning( + f"Unknown message type from slave {connection_id}: {msg_type}" + ) + + except Exception as e: + print(f"๐Ÿ” DEBUG: Error handling slave message: {e}") + logger.error(f"Error handling slave message: {e}") + + +# ============= UTILITY FUNCTIONS ============= + + +async def broadcast_to_slaves(robot_id: str, message: dict): + """Broadcast message to all slaves of a robot""" + slave_connections = connection_manager.get_slave_connections(robot_id) + print(f"๐Ÿ” DEBUG: Broadcasting to slaves for robot {robot_id}") + print(f"๐Ÿ” DEBUG: Found {len(slave_connections)} slave connections") + print(f"๐Ÿ” DEBUG: Message to broadcast: {message}") + if slave_connections: + logger.info( + f"๐Ÿ“ก Broadcasting to {len(slave_connections)} slaves for robot {robot_id}" + ) + results = await asyncio.gather( + *[ws.send_json(message) for ws in slave_connections], return_exceptions=True + ) + print(f"๐Ÿ” DEBUG: Broadcast results: {results}") + + +async def broadcast_to_master(robot_id: str, message: dict): + """Send message to master of a robot""" + master_ws = connection_manager.get_master_connection(robot_id) + print(f"๐Ÿ” DEBUG: Broadcasting to master for robot {robot_id}") + print(f"๐Ÿ” DEBUG: Master connection found: {master_ws is not None}") + print(f"๐Ÿ” DEBUG: Message to send: {message}") + if master_ws: + await master_ws.send_json(message) + print("๐Ÿ” DEBUG: Message sent to master successfully") + + +async def sync_slave_with_current_state(robot_id: str, websocket: WebSocket): + """Send current robot state to newly connected slave""" + robot = robot_manager.get_robot(robot_id) + if robot: + await websocket.send_json({ + "type": "sync_state", + "timestamp": datetime.now(UTC).isoformat(), + "data": robot.model_dump(mode="json"), + }) + + +if __name__ == "__main__": + import uvicorn + + """Start the LeRobot Arena server""" + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + + logger = logging.getLogger("lerobot-arena") + logger.info("๐Ÿš€ Starting LeRobot Arena WebSocket Server...") + + # Start the server + uvicorn.run( + app, + host="0.0.0.0", + port=8080, + log_level="info", + reload=False, # Auto-reload on code changes + ) diff --git a/src-python/src/models.py b/src-python/src/models.py new file mode 100644 index 0000000000000000000000000000000000000000..0038d1cb746255d0d878c131b353df3ab78e350c --- /dev/null +++ b/src-python/src/models.py @@ -0,0 +1,191 @@ +from pydantic import BaseModel, Field +from typing import Dict, List, Optional, Any, Literal +from datetime import datetime +from enum import Enum + +# ============= ROBOT COMMAND MODELS ============= + +class JointUpdate(BaseModel): + name: str + value: float + speed: Optional[float] = None + +class RobotCommand(BaseModel): + timestamp: int + joints: List[JointUpdate] + duration: Optional[int] = None + metadata: Optional[Dict[str, Any]] = None + +class CommandSequence(BaseModel): + id: str + name: str + commands: List[RobotCommand] + loop: Optional[bool] = False + total_duration: int + +# ============= COMMUNICATION MODELS ============= + +class ControlMessage(BaseModel): + type: Literal["command", "sequence", "heartbeat", "status_request"] + timestamp: str + data: Optional[Dict[str, Any]] = None + +class StatusMessage(BaseModel): + type: Literal["status", "joint_states", "error", "heartbeat_ack", "slave_status"] + timestamp: str + slave_id: Optional[str] = None + data: Optional[Dict[str, Any]] = None + +# ============= ROBOT MODELS ============= + +class JointLimits(BaseModel): + lower: Optional[float] = None + upper: Optional[float] = None + velocity: Optional[float] = None + effort: Optional[float] = None + +class DriverJointState(BaseModel): + name: str + servo_id: int + type: Literal["revolute", "continuous"] + virtual_value: float + real_value: Optional[float] = None + limits: Optional[JointLimits] = None + +class ConnectionStatus(BaseModel): + is_connected: bool + last_connected: Optional[datetime] = None + error: Optional[str] = None + +# ============= ROBOT MANAGEMENT ============= + +class Robot(BaseModel): + id: str + name: str + robot_type: str + created_at: datetime + joints: List[DriverJointState] = [] + + # Connection state + master_connected: bool = False + master_connection_id: Optional[str] = None + slave_connections: List[str] = [] + + # Control state + last_command_source: Literal["master", "manual", "none"] = "none" + last_command_time: Optional[datetime] = None + +class RobotStatus(BaseModel): + robot_id: str + master_connected: bool + slave_count: int + last_command_source: str + last_command_time: Optional[datetime] + last_seen: datetime + +class CreateRobotRequest(BaseModel): + name: Optional[str] = None + robot_type: str = "so-arm100" + +# ============= DEMO SEQUENCES ============= + +DEMO_SEQUENCES = [ + CommandSequence( + id="gentle-wave", + name="Gentle Wave Pattern", + total_duration=6000, + commands=[ + RobotCommand( + timestamp=0, + joints=[ + JointUpdate(name="Rotation", value=-10), + JointUpdate(name="Pitch", value=8), + JointUpdate(name="Elbow", value=-12) + ], + duration=2000 + ), + RobotCommand( + timestamp=2000, + joints=[JointUpdate(name="Wrist_Roll", value=10)], + duration=1000 + ), + RobotCommand( + timestamp=3000, + joints=[JointUpdate(name="Wrist_Roll", value=-10)], + duration=1000 + ), + RobotCommand( + timestamp=4000, + joints=[ + JointUpdate(name="Wrist_Roll", value=0), + JointUpdate(name="Rotation", value=0), + JointUpdate(name="Pitch", value=0), + JointUpdate(name="Elbow", value=0) + ], + duration=2000 + ) + ] + ), + CommandSequence( + id="small-scan", + name="Small Scanning Pattern", + total_duration=8000, + commands=[ + RobotCommand( + timestamp=0, + joints=[ + JointUpdate(name="Rotation", value=-15), + JointUpdate(name="Pitch", value=10) + ], + duration=2000 + ), + RobotCommand( + timestamp=2000, + joints=[JointUpdate(name="Rotation", value=15)], + duration=3000 + ), + RobotCommand( + timestamp=5000, + joints=[ + JointUpdate(name="Rotation", value=0), + JointUpdate(name="Pitch", value=0) + ], + duration=3000 + ) + ] + ), + CommandSequence( + id="tiny-flex", + name="Tiny Flex Pattern", + total_duration=8000, + commands=[ + RobotCommand( + timestamp=0, + joints=[ + JointUpdate(name="Elbow", value=-15), + JointUpdate(name="Wrist_Pitch", value=8) + ], + duration=2000 + ), + RobotCommand( + timestamp=2000, + joints=[JointUpdate(name="Jaw", value=8)], + duration=1000 + ), + RobotCommand( + timestamp=3000, + joints=[JointUpdate(name="Elbow", value=-25)], + duration=2000 + ), + RobotCommand( + timestamp=5000, + joints=[ + JointUpdate(name="Jaw", value=0), + JointUpdate(name="Elbow", value=0), + JointUpdate(name="Wrist_Pitch", value=0) + ], + duration=3000 + ) + ] + ) +] \ No newline at end of file diff --git a/src-python/src/robot_manager.py b/src-python/src/robot_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..7c2154787ae1c4ef9dc7758154dd048de198df1f --- /dev/null +++ b/src-python/src/robot_manager.py @@ -0,0 +1,168 @@ +from typing import Dict, Optional, List +from datetime import datetime, timezone +import logging + +from .models import Robot, RobotStatus, DriverJointState + +logger = logging.getLogger(__name__) + +class RobotManager: + """Manages robot lifecycle and state""" + + def __init__(self): + self.robots: Dict[str, Robot] = {} + + async def create_robot(self, robot_id: str, robot_type: str, name: str) -> Robot: + """Create a new robot""" + if robot_id in self.robots: + raise ValueError(f"Robot {robot_id} already exists") + + # Create demo joint configuration for so-arm100 + joints = self._create_demo_joints(robot_type) + + robot = Robot( + id=robot_id, + name=name, + robot_type=robot_type, + created_at=datetime.now(timezone.utc), + joints=joints + ) + + self.robots[robot_id] = robot + logger.info(f"Created robot {robot_id} ({robot_type}) with {len(joints)} joints") + + return robot + + def get_robot(self, robot_id: str) -> Optional[Robot]: + """Get robot by ID""" + return self.robots.get(robot_id) + + async def delete_robot(self, robot_id: str): + """Delete a robot""" + if robot_id in self.robots: + del self.robots[robot_id] + logger.info(f"Deleted robot {robot_id}") + + async def set_master_connected(self, robot_id: str, connection_id: str): + """Mark robot as having a master connection""" + robot = self.robots.get(robot_id) + if robot: + robot.master_connected = True + robot.master_connection_id = connection_id + robot.last_command_source = "master" + robot.last_command_time = datetime.now(timezone.utc) + + async def set_master_disconnected(self, robot_id: str): + """Mark robot as having no master connection""" + robot = self.robots.get(robot_id) + if robot: + robot.master_connected = False + robot.master_connection_id = None + robot.last_command_source = "none" + + async def add_slave_connection(self, robot_id: str, connection_id: str): + """Add a slave connection to robot""" + robot = self.robots.get(robot_id) + if robot: + if connection_id not in robot.slave_connections: + robot.slave_connections.append(connection_id) + logger.info(f"Added slave {connection_id} to robot {robot_id} ({len(robot.slave_connections)} total)") + + async def remove_slave_connection(self, robot_id: str, connection_id: str): + """Remove a slave connection from robot""" + robot = self.robots.get(robot_id) + if robot: + try: + robot.slave_connections.remove(connection_id) + logger.info(f"Removed slave {connection_id} from robot {robot_id} ({len(robot.slave_connections)} remaining)") + except ValueError: + logger.warning(f"Slave {connection_id} not found in robot {robot_id} connections") + + def get_robot_status(self, robot_id: str) -> Optional[RobotStatus]: + """Get robot connection status""" + robot = self.robots.get(robot_id) + if not robot: + return None + + return RobotStatus( + robot_id=robot_id, + master_connected=robot.master_connected, + slave_count=len(robot.slave_connections), + last_command_source=robot.last_command_source, + last_command_time=robot.last_command_time, + last_seen=datetime.now(timezone.utc) + ) + + def list_robots_with_masters(self) -> List[Robot]: + """Get all robots that have master connections""" + return [robot for robot in self.robots.values() if robot.master_connected] + + def list_robots_with_slaves(self) -> List[Robot]: + """Get all robots that have slave connections""" + return [robot for robot in self.robots.values() if robot.slave_connections] + + def _create_demo_joints(self, robot_type: str) -> List[DriverJointState]: + """Create demo joint configuration based on robot type""" + if robot_type == "so-arm100": + return [ + DriverJointState( + name="Rotation", + servo_id=1, + type="revolute", + virtual_value=0.0, + real_value=0.0 + ), + DriverJointState( + name="Pitch", + servo_id=2, + type="revolute", + virtual_value=0.0, + real_value=0.0 + ), + DriverJointState( + name="Elbow", + servo_id=3, + type="revolute", + virtual_value=0.0, + real_value=0.0 + ), + DriverJointState( + name="Wrist_Pitch", + servo_id=4, + type="revolute", + virtual_value=0.0, + real_value=0.0 + ), + DriverJointState( + name="Wrist_Roll", + servo_id=5, + type="revolute", + virtual_value=0.0, + real_value=0.0 + ), + DriverJointState( + name="Jaw", + servo_id=6, + type="revolute", + virtual_value=0.0, + real_value=0.0 + ) + ] + else: + # Default generic robot + return [ + DriverJointState( + name="joint_1", + servo_id=1, + type="revolute", + virtual_value=0.0, + real_value=0.0 + ), + DriverJointState( + name="joint_2", + servo_id=2, + type="revolute", + virtual_value=0.0, + real_value=0.0 + ) + ] \ No newline at end of file diff --git a/src-python/start_server.py b/src-python/start_server.py new file mode 100644 index 0000000000000000000000000000000000000000..4df549cf869e6ce584ebd2c7afea3b75e8c24551 --- /dev/null +++ b/src-python/start_server.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +""" +LeRobot Arena WebSocket Server + +Run with: python start_server.py +""" + +import uvicorn +import logging +import sys +from pathlib import Path + +# Add src to path +sys.path.insert(0, str(Path(__file__).parent / "src")) + +from src.main import app + +def main(): + """Start the LeRobot Arena server""" + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" + ) + + logger = logging.getLogger("lerobot-arena") + logger.info("๐Ÿš€ Starting LeRobot Arena WebSocket Server...") + + # Start the server + uvicorn.run( + app, + host="0.0.0.0", + port=8080, + log_level="info", + reload=False # Auto-reload on code changes + ) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src-python/uv.lock b/src-python/uv.lock new file mode 100644 index 0000000000000000000000000000000000000000..0014dfa9d327dbbf2b139e271256b4339feccec6 --- /dev/null +++ b/src-python/uv.lock @@ -0,0 +1,239 @@ +version = 1 +revision = 2 +requires-python = ">=3.12" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949, upload-time = "2025-03-17T00:02:54.77Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" }, +] + +[[package]] +name = "click" +version = "8.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "fastapi" +version = "0.115.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/55/ae499352d82338331ca1e28c7f4a63bfd09479b16395dce38cf50a39e2c2/fastapi-0.115.12.tar.gz", hash = "sha256:1e2c2a2646905f9e83d32f04a3f86aff4a286669c6c950ca95b5fd68c2602681", size = 295236, upload-time = "2025-03-23T22:55:43.822Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/b3/b51f09c2ba432a576fe63758bddc81f78f0c6309d9e5c10d194313bf021e/fastapi-0.115.12-py3-none-any.whl", hash = "sha256:e94613d6c05e27be7ffebdd6ea5f388112e5e430c8f7d6494a9d1d88d43e814d", size = 95164, upload-time = "2025-03-23T22:55:42.101Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "lerobot-arena-server" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "fastapi" }, + { name = "uvicorn" }, + { name = "websockets" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.115.12" }, + { name = "uvicorn", specifier = ">=0.34.3" }, + { name = "websockets", specifier = ">=15.0.1" }, +] + +[[package]] +name = "pydantic" +version = "2.11.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/86/8ce9040065e8f924d642c58e4a344e33163a07f6b57f836d0d734e0ad3fb/pydantic-2.11.5.tar.gz", hash = "sha256:7f853db3d0ce78ce8bbb148c401c2cdd6431b3473c0cdff2755c7690952a7b7a", size = 787102, upload-time = "2025-05-22T21:18:08.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/69/831ed22b38ff9b4b64b66569f0e5b7b97cf3638346eb95a2147fdb49ad5f/pydantic-2.11.5-py3-none-any.whl", hash = "sha256:f9c26ba06f9747749ca1e5c94d6a85cb84254577553c8785576fd38fa64dc0f7", size = 444229, upload-time = "2025-05-22T21:18:06.329Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, + { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, + { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, + { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, + { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, + { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, + { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, + { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "starlette" +version = "0.46.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846, upload-time = "2025-04-13T13:56:17.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload-time = "2025-04-10T14:19:03.967Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.34.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/ad/713be230bcda622eaa35c28f0d328c3675c371238470abdea52417f17a8e/uvicorn-0.34.3.tar.gz", hash = "sha256:35919a9a979d7a59334b6b10e05d77c1d0d574c50e0fc98b8b1a0f165708b55a", size = 76631, upload-time = "2025-06-01T07:48:17.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/0d/8adfeaa62945f90d19ddc461c55f4a50c258af7662d34b6a3d5d1f8646f6/uvicorn-0.34.3-py3-none-any.whl", hash = "sha256:16246631db62bdfbf069b0645177d6e8a77ba950cfedbfd093acef9444e4d885", size = 62431, upload-time = "2025-06-01T07:48:15.664Z" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] diff --git a/src/app.css b/src/app.css new file mode 100644 index 0000000000000000000000000000000000000000..d4b5078586e291f9dc7d6f04a380aa3c1b52b84b --- /dev/null +++ b/src/app.css @@ -0,0 +1 @@ +@import 'tailwindcss'; diff --git a/src/app.d.ts b/src/app.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..da08e6da592d210d5cc574d8a629868eced88543 --- /dev/null +++ b/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/src/app.html b/src/app.html new file mode 100644 index 0000000000000000000000000000000000000000..77a5ff52c9239ef2a5c38ba452c659f49e64a7db --- /dev/null +++ b/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/src/lib/components/panel/ControlPanel.svelte b/src/lib/components/panel/ControlPanel.svelte new file mode 100644 index 0000000000000000000000000000000000000000..36293884f3bb887061533db5423568a3bb866771 --- /dev/null +++ b/src/lib/components/panel/ControlPanel.svelte @@ -0,0 +1,60 @@ + + +
+ + + +
+ + \ No newline at end of file diff --git a/src/lib/components/panel/Panels.svelte b/src/lib/components/panel/Panels.svelte new file mode 100644 index 0000000000000000000000000000000000000000..dced5babf53d1e25dd77b37977afe656a6694aec --- /dev/null +++ b/src/lib/components/panel/Panels.svelte @@ -0,0 +1,78 @@ + + +{#if isOpen} +
+ +
+{:else} +
+
+

Robot Controls

+
+ Robot +
+ +
+ + +
+ + +
+ +
+ {#if activeTab === 'controls'} + + {:else if activeTab === 'settings'} + + {/if} +
+
+{/if} + + diff --git a/src/lib/components/panel/RobotControlPanel.svelte b/src/lib/components/panel/RobotControlPanel.svelte new file mode 100644 index 0000000000000000000000000000000000000000..b47856e7ac79610bd49d7161af9b8ca1e9d90ce7 --- /dev/null +++ b/src/lib/components/panel/RobotControlPanel.svelte @@ -0,0 +1,649 @@ + + +
+
+

Robot Control - Master/Slave Architecture

+
+ {robots.length} robots, {robotsWithMaster} with masters, {robotsWithSlaves} with slaves +
+
+ + + {#if error} +
+ {error} + +
+ {/if} + + +
+

Create Robot

+ +
+ + + +
+
+ + +
+ {#each robots as robot (robot.id)} +
+
+
+

{robot.id}

+
+ Status: + + {getConnectionStatusText(robot)} + + {#if robot.controlState.lastCommandSource !== "none"} + โ€ข Last: {robot.controlState.lastCommandSource} + {/if} + {#if robot.isCalibrated} + โ€ข Calibrated + {/if} +
+
+ + +
+ + +
+
+ Masters (Control Sources) + {#if robot.controlState.hasActiveMaster} + - Connected: {robot.controlState.masterName} + {:else} + - None Connected + {/if} +
+ +
+ + + + + + + {#if robot.controlState.hasActiveMaster} + + {/if} +
+
+ + +
+
+ Slaves (Execution Targets) + - {robot.connectedSlaves.length} Connected + / {robot.slaves.length} Total +
+ +
+ + + +
+ + {#if robot.slaves.length > 0} +
+ Connected Slaves: + {#each robot.slaves as slave} +
+ {slave.name} ({slave.id}) + +
+ {/each} +
+ {/if} +
+ + + {#if robot.connectedSlaves.some(slave => slave.name.includes("USB"))} +
+
+ USB Robot Calibration +
+ {#if !robot.isCalibrated} +
+ 1. Manually position your robot to match the digital twin's rest pose
+ 2. Click "Calibrate" when positioned correctly +
+
+ + +
+ {:else} +
+ โœ“ Robot calibrated at {robot.calibrationState.calibrationTime?.toLocaleTimeString()} +
+ + {/if} +
+ {/if} + + + {#if robot.manualControlEnabled} +
+
Manual Control ({robot.activeJoints.length} active joints)
+ {#each robot.activeJoints as joint} +
+
+ {joint.name} +
+ {joint.virtualValue.toFixed(0)}ยฐ + {#if joint.realValue !== undefined} + + Real: {joint.realValue.toFixed(0)}ยฐ + + {:else} + N/A + {/if} +
+
+ { + const target = e.target as HTMLInputElement; + robot.updateJointValue(joint.name, parseFloat(target.value)); + }} + class="joint-slider" + /> +
+ {/each} + + {#if robot.activeJoints.length === 0} +
No active joints
+ {/if} +
+ {:else} +
+
+ ๐ŸŽฎ Master Control Active
+ + Robot is controlled by: {robot.controlState.masterName}
+ Manual controls are disabled. Disconnect master to regain manual control. +
+
+
+ {/if} +
+ {/each} + + {#if robots.length === 0} +
+ No robots created yet. Create one above to get started with the master-slave architecture. +
+ {/if} +
+
+ + +{#if showRobotSelectionModal} +
+
+

Select Server Robot

+ +
+ + +
+ +
+ This will connect your local robot "{pendingLocalRobot?.id}" as a slave to + receive commands from the selected server robot. +
+ +
+ + +
+
+
+{/if} + + \ No newline at end of file diff --git a/src/lib/components/panel/SettingsPanel.svelte b/src/lib/components/panel/SettingsPanel.svelte new file mode 100644 index 0000000000000000000000000000000000000000..4ad683bca472943e4df8fdc51e5c383749c77846 --- /dev/null +++ b/src/lib/components/panel/SettingsPanel.svelte @@ -0,0 +1,248 @@ + + +
+

Robot Joint Settings

+ + {#if robots.length === 0} +
+ No robots in the environment. Create a robot first using the Control Panel. +
+ {:else} + {#each robots as [robotId, robotState]} +
+

+ Robot: {robotId.slice(0, 8)}... +

+ + {#if robotState.robot.joints.length === 0} +
No joints found for this robot.
+ {:else} +
+ Joints: +
+
+ {#each robotState.robot.joints as joint, jointIndex} + {#if joint.type === 'revolute'} + {@const currentValue = getJointRotationValue(joint)} + {@const limits = getJointLimits(joint)} + {@const axis = joint.axis_xyz || [0, 0, 1]} + +
+
+
+
+ {joint.name || `Joint ${jointIndex}`} + ({joint.type}) +
+
+ Axis: [{axis[0].toFixed(1)}, {axis[1].toFixed(1)}, {axis[2].toFixed(1)}] +
+
+
+
{currentValue.toFixed(1)}ยฐ
+
+ {limits.min.toFixed(0)}ยฐ to {limits.max.toFixed(0)}ยฐ +
+
+
+ + +
+ updateJointRotation(robotId, jointIndex, parseFloat(e.currentTarget.value))} + class="w-full h-2 bg-slate-600 rounded-lg appearance-none cursor-pointer slider" + /> +
+ + + +
+ {:else if joint.type === 'continuous'} + {@const currentValue = getJointRotationValue(joint)} + {@const limits = getJointLimits(joint)} + {@const axis = joint.axis_xyz || [0, 0, 1]} +
+
+
+
+ {joint.name || `Joint ${jointIndex}`} + ({joint.type}) +
+
+ Axis: [{axis[0].toFixed(1)}, {axis[1].toFixed(1)}, {axis[2].toFixed(1)}] +
+
+
+
{currentValue.toFixed(1)}ยฐ
+
+ {limits.min.toFixed(0)}ยฐ to {limits.max.toFixed(0)}ยฐ +
+
+
+
+ {:else if joint.type === 'fixed'} +
+
+ {joint.name || `Joint ${jointIndex}`} + (fixed joint) +
+
+ {/if} + {/each} +
+ {#if robotState.urdfConfig.compoundMovements} +
+ Compound Movements: +
+
+ {#each robotState.urdfConfig.compoundMovements as movement} +
+ {movement.name} + {#each movement.dependents as dependent} +
+ {dependent.joint} +
+ {/each} +
+ {/each} +
+ {/if} + {/if} + + + +
+ {/each} + {/if} +
+ + diff --git a/src/lib/components/scene/Floor.svelte b/src/lib/components/scene/Floor.svelte new file mode 100644 index 0000000000000000000000000000000000000000..e96e7f3113688f9233ec9fa3463e9d9c00aff5ed --- /dev/null +++ b/src/lib/components/scene/Floor.svelte @@ -0,0 +1,56 @@ + + + + + + + diff --git a/src/lib/components/scene/Robot.svelte b/src/lib/components/scene/Robot.svelte new file mode 100644 index 0000000000000000000000000000000000000000..6a9dcb0ed80479ae48e811f7431f242f252f981a --- /dev/null +++ b/src/lib/components/scene/Robot.svelte @@ -0,0 +1,44 @@ + + +{#each robots as robot, index (robot.id)} + {@const xPosition = index * 5} + + {#each getRootLinks(robot.robotState.robot) as link} + + {/each} + +{/each} \ No newline at end of file diff --git a/src/lib/components/scene/Selectable.svelte b/src/lib/components/scene/Selectable.svelte new file mode 100644 index 0000000000000000000000000000000000000000..978e707895c2c036c4df66deae88c34dded43989 --- /dev/null +++ b/src/lib/components/scene/Selectable.svelte @@ -0,0 +1,68 @@ + + +{#if selected && enableEdit} + + {@render children?.()} + +{:else} + + {@render children?.()} + +{/if} + + diff --git a/src/lib/components/scene/robot/URDF/createRobot.svelte.ts b/src/lib/components/scene/robot/URDF/createRobot.svelte.ts new file mode 100644 index 0000000000000000000000000000000000000000..90f376e369f29e7783023c7359a771996ee3a789 --- /dev/null +++ b/src/lib/components/scene/robot/URDF/createRobot.svelte.ts @@ -0,0 +1,27 @@ +import type { RobotState } from "$lib/types/robot"; +import type { RobotUrdfConfig } from "$lib/types/urdf"; +import { UrdfParser } from "./utils/UrdfParser"; + +export async function createRobot(urdfConfig: RobotUrdfConfig): Promise { + const customParser = new UrdfParser(urdfConfig.urdfUrl, "/robots/so-100/"); + const urdfData = await customParser.load(); + const robot = customParser.fromString(urdfData); + + // Make the robot data reactive so mutations to joint.rotation trigger reactivity + const reactiveRobot = $state(robot); + + // Make the selection state reactive as well + const reactiveSelection = $state({ + isSelected: false, + selectedLink: undefined, + selectedJoint: undefined + }); + + const robotState: RobotState = { + robot: reactiveRobot, + urdfConfig: urdfConfig, + selection: reactiveSelection + }; + + return robotState; +} \ No newline at end of file diff --git a/src/lib/components/scene/robot/URDF/interfaces/IUrdfBox.ts b/src/lib/components/scene/robot/URDF/interfaces/IUrdfBox.ts new file mode 100644 index 0000000000000000000000000000000000000000..9e3a8b392567f3d6d68ac24fac01f43ce1d22a15 --- /dev/null +++ b/src/lib/components/scene/robot/URDF/interfaces/IUrdfBox.ts @@ -0,0 +1,3 @@ +export default interface IUrdfBox { + size: [x: number, y: number, z: number] + } \ No newline at end of file diff --git a/src/lib/components/scene/robot/URDF/interfaces/IUrdfCylinder.ts b/src/lib/components/scene/robot/URDF/interfaces/IUrdfCylinder.ts new file mode 100644 index 0000000000000000000000000000000000000000..0ddb9a193db234f89835965b8a706373125b84a7 --- /dev/null +++ b/src/lib/components/scene/robot/URDF/interfaces/IUrdfCylinder.ts @@ -0,0 +1,4 @@ +export default interface IUrdfCylinder { + radius: number; + length: number + } \ No newline at end of file diff --git a/src/lib/components/scene/robot/URDF/interfaces/IUrdfJoint.ts b/src/lib/components/scene/robot/URDF/interfaces/IUrdfJoint.ts new file mode 100644 index 0000000000000000000000000000000000000000..182b8bc82294f034a5a933d8f38bc0e100c4408a --- /dev/null +++ b/src/lib/components/scene/robot/URDF/interfaces/IUrdfJoint.ts @@ -0,0 +1,42 @@ +import type IUrdfLink from "./IUrdfLink" + +export default interface IUrdfJoint { + name?: string + type?: 'revolute' | 'continuous' | 'prismatic' | 'fixed' | 'floating' | 'planar' + // rpy = roll, pitch, yaw (values between -pi and +pi) + origin_rpy: [roll: number, pitch: number, yaw: number] + origin_xyz: [x: number, y: number, z: number] + // calculated rotation for non-fixed joints based on origin_rpy and axis_xyz + rotation: [x: number, y: number, z: number] + parent: IUrdfLink + child: IUrdfLink + // axis for revolute and continuous joints defaults to (1,0,0) + axis_xyz?: [x: number, y: number, z: number] + calibration?: { + rising?: number, // Calibration rising value in radians + falling?: number // Calibration falling value in radians + } + dynamics?: { + damping?: number + friction?: number + } + // only for revolute joints + limit?: { + lower?: number + upper?: number + effort: number + velocity: number + } + mimic?: { + joint: string + multiplier?: number + offset?: number + } + safety_controller?: { + soft_lower_limit?: number + soft_upper_limit?: number + k_position?: number + k_velocity: number + } + elem: Element +} \ No newline at end of file diff --git a/src/lib/components/scene/robot/URDF/interfaces/IUrdfLink.ts b/src/lib/components/scene/robot/URDF/interfaces/IUrdfLink.ts new file mode 100644 index 0000000000000000000000000000000000000000..1fad76197f062000783bdc582bbf2fadbbcc176f --- /dev/null +++ b/src/lib/components/scene/robot/URDF/interfaces/IUrdfLink.ts @@ -0,0 +1,23 @@ +import type {IUrdfVisual} from "./IUrdfVisual" + +interface IUrdfInertia { + ixx: number; + ixy: number; + ixz: number; + iyy: number; + iyz: number; + izz: number; +} + +export default interface IUrdfLink { + name: string + inertial?: { + origin_xyz?: [x: number, y: number, z: number] + origin_rpy?: [roll: number, pitch: number, yaw: number] + mass: number + inertia: IUrdfInertia + } + visual: IUrdfVisual[] + collision: IUrdfVisual[] + elem: Element +} \ No newline at end of file diff --git a/src/lib/components/scene/robot/URDF/interfaces/IUrdfMesh.ts b/src/lib/components/scene/robot/URDF/interfaces/IUrdfMesh.ts new file mode 100644 index 0000000000000000000000000000000000000000..b55e7d9fff5451d2315a1b8d940c69197c0f6a5f --- /dev/null +++ b/src/lib/components/scene/robot/URDF/interfaces/IUrdfMesh.ts @@ -0,0 +1,5 @@ +export default interface IUrdfMesh { + filename: string; + type: 'stl' | 'fbx' | 'obj' | 'dae'; + scale: [x: number, y: number, z: number]; + } \ No newline at end of file diff --git a/src/lib/components/scene/robot/URDF/interfaces/IUrdfRobot.ts b/src/lib/components/scene/robot/URDF/interfaces/IUrdfRobot.ts new file mode 100644 index 0000000000000000000000000000000000000000..6b921251b73fc78ba66320bbccc965055fff465c --- /dev/null +++ b/src/lib/components/scene/robot/URDF/interfaces/IUrdfRobot.ts @@ -0,0 +1,10 @@ +import type IUrdfJoint from "./IUrdfJoint" +import type IUrdfLink from "./IUrdfLink" + +export default interface IUrdfRobot { + name: string + links: {[name: string]: IUrdfLink} + joints: IUrdfJoint[] + // the DOM element holding the XML, so we can work non-destructive + elem?: Element +} \ No newline at end of file diff --git a/src/lib/components/scene/robot/URDF/interfaces/IUrdfVisual.ts b/src/lib/components/scene/robot/URDF/interfaces/IUrdfVisual.ts new file mode 100644 index 0000000000000000000000000000000000000000..cb37d10eb1788bf3b2be90993449d1e02dc25fd1 --- /dev/null +++ b/src/lib/components/scene/robot/URDF/interfaces/IUrdfVisual.ts @@ -0,0 +1,56 @@ +import type IUrdfBox from "./IUrdfBox"; +import type IUrdfCylinder from "./IUrdfCylinder"; +import type IUrdfMesh from "./IUrdfMesh"; + +// 1) Boxโ€type visual +interface IUrdfVisualBox { + name: string; + origin_xyz: [x: number, y: number, z: number]; + origin_rpy: [roll: number, pitch: number, yaw: number]; + geometry: IUrdfBox; + material?: { + name: string; + color?: string; + texture?: string; + }, + type: 'box'; + // optional RGBA color override + color_rgba?: [r: number, g: number, b: number, a: number]; + // XML Element reference + elem: Element; +} + +// 2) Cylinderโ€type visual +interface IUrdfVisualCylinder { + name: string; + origin_xyz: [x: number, y: number, z: number]; + origin_rpy: [roll: number, pitch: number, yaw: number]; + geometry: IUrdfCylinder; + material?: { + name: string; + color?: string; + texture?: string; + }, + type: 'cylinder'; + color_rgba?: [r: number, g: number, b: number, a: number]; + elem: Element; +} + +// 3) Meshโ€type visual +interface IUrdfVisualMesh { + name: string; + origin_xyz: [x: number, y: number, z: number]; + origin_rpy: [roll: number, pitch: number, yaw: number]; + geometry: IUrdfMesh; + material?: { + name: string; + color?: string; + texture?: string; + }, + type: 'mesh'; + color_rgba?: [r: number, g: number, b: number, a: number]; + elem: Element; +} + +// Now make a union of the three: +export type IUrdfVisual = IUrdfVisualBox | IUrdfVisualCylinder | IUrdfVisualMesh; diff --git a/src/lib/components/scene/robot/URDF/interfaces/index.ts b/src/lib/components/scene/robot/URDF/interfaces/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..1550aaaab3157fc8ee031ff4e4a099b658cee02d --- /dev/null +++ b/src/lib/components/scene/robot/URDF/interfaces/index.ts @@ -0,0 +1,7 @@ +export * from './IUrdfBox'; +export * from './IUrdfCylinder'; +export * from './IUrdfJoint'; +export * from './IUrdfLink'; +export * from './IUrdfMesh'; +export * from './IUrdfRobot'; +export * from './IUrdfVisual'; diff --git a/src/lib/components/scene/robot/URDF/mesh/DAE.svelte b/src/lib/components/scene/robot/URDF/mesh/DAE.svelte new file mode 100644 index 0000000000000000000000000000000000000000..813c0ca1c6bf6936de5c8befd88f6e9ececc9a13 --- /dev/null +++ b/src/lib/components/scene/robot/URDF/mesh/DAE.svelte @@ -0,0 +1,70 @@ + + +{#await load(filename) then dae} + {@html ``} + + + + {#each dae.scene.children as obj} + {#if obj.type === 'Mesh'} + {@const mesh = obj as Mesh} + + + + {/if} + {/each} + + + +{/await} + + diff --git a/src/lib/components/scene/robot/URDF/mesh/OBJ.svelte b/src/lib/components/scene/robot/URDF/mesh/OBJ.svelte new file mode 100644 index 0000000000000000000000000000000000000000..4931e88dd69ba823ae56c4523397a0be962785b2 --- /dev/null +++ b/src/lib/components/scene/robot/URDF/mesh/OBJ.svelte @@ -0,0 +1,52 @@ + + +{#await load(filename) then obj} + {@html ``} + + + +{/await} + + diff --git a/src/lib/components/scene/robot/URDF/mesh/STL.svelte b/src/lib/components/scene/robot/URDF/mesh/STL.svelte new file mode 100644 index 0000000000000000000000000000000000000000..1613ea8fca4b99e44f23e22341b81e6e21232985 --- /dev/null +++ b/src/lib/components/scene/robot/URDF/mesh/STL.svelte @@ -0,0 +1,51 @@ + + +{#await load(filename) then stl} + {@html ``} + + + +{/await} + + diff --git a/src/lib/components/scene/robot/URDF/primitives/UrdfJoint.svelte b/src/lib/components/scene/robot/URDF/primitives/UrdfJoint.svelte new file mode 100644 index 0000000000000000000000000000000000000000..7adeb69d56febf292b87e68f56f74bd2e7b7d5d7 --- /dev/null +++ b/src/lib/components/scene/robot/URDF/primitives/UrdfJoint.svelte @@ -0,0 +1,148 @@ + + +{@html ``} +{#if showName} + + + +{/if} + + +{#if showLine} + + + + +{/if} + + + + {#if joint.child} + + {/if} + + {#if showLine} + + + + + + + + + + {/if} + + + + diff --git a/src/lib/components/scene/robot/URDF/primitives/UrdfLink.svelte b/src/lib/components/scene/robot/URDF/primitives/UrdfLink.svelte new file mode 100644 index 0000000000000000000000000000000000000000..10afaa600bb7836f336715c89c0d1b9df03fa8ae --- /dev/null +++ b/src/lib/components/scene/robot/URDF/primitives/UrdfLink.svelte @@ -0,0 +1,112 @@ + + +{@html ``} +{#if showName} + +{/if} + +{#if showVisual} + {#each link.visual as visual} + + {/each} +{/if} + +{#if showCollision} + {#each link.collision as visual} + + {/each} +{/if} + +{#each getChildJoints(robot, link) as joint} + +{/each} + + diff --git a/src/lib/components/scene/robot/URDF/primitives/UrdfThree.svelte b/src/lib/components/scene/robot/URDF/primitives/UrdfThree.svelte new file mode 100644 index 0000000000000000000000000000000000000000..a2e1e50971349c8cf4efcadaf16fd950e5fe1362 --- /dev/null +++ b/src/lib/components/scene/robot/URDF/primitives/UrdfThree.svelte @@ -0,0 +1,80 @@ + + + + {#each getRootLinks(robot) as link} + + {/each} + + + diff --git a/src/lib/components/scene/robot/URDF/primitives/UrdfVisual.svelte b/src/lib/components/scene/robot/URDF/primitives/UrdfVisual.svelte new file mode 100644 index 0000000000000000000000000000000000000000..2ecdfb802a3a02558fd9da9885358f4371c27c48 --- /dev/null +++ b/src/lib/components/scene/robot/URDF/primitives/UrdfVisual.svelte @@ -0,0 +1,109 @@ + + +{#if visual.type === 'mesh'} + {#if visual.geometry.type === 'stl'} + + {:else if visual.geometry.type === 'obj'} + + {:else if visual.geometry.type === 'dae'} + + {/if} +{:else if visual.type === 'cylinder'} + + + + +{:else if visual.type === 'box'} + + + + +{/if} diff --git a/src/lib/components/scene/robot/URDF/runes/urdf_state.svelte.ts b/src/lib/components/scene/robot/URDF/runes/urdf_state.svelte.ts new file mode 100644 index 0000000000000000000000000000000000000000..241d89bbc215f70b0a1b37f49be5b93c789d7746 --- /dev/null +++ b/src/lib/components/scene/robot/URDF/runes/urdf_state.svelte.ts @@ -0,0 +1,144 @@ +import type IUrdfJoint from "../interfaces/IUrdfJoint" +import type IUrdfLink from "../interfaces/IUrdfLink" +import type IUrdfRobot from "../interfaces/IUrdfRobot" + +// Color constants for better maintainability +export const URDF_COLORS = { + COLLISION: "#813d9c", // purple + JOINT: "#62a0ea", // blue + LINK: "#57e389", // green + JOINT_INDICATOR: "#f66151", // red + HIGHLIGHT: "#ffa348", // orange + BACKGROUND: "#241f31", // dark purple +} as const; + +// Transform tool types +export type TransformTool = "translate" | "rotate" | "scale"; + +// Joint state tracking +export interface JointStates { + continuous: Record; + revolute: Record; +} + +// Selection state +export interface SelectionState { + selectedLink?: IUrdfLink; + selectedJoint?: IUrdfJoint; +} + +// Visibility configuration +export interface VisibilityConfig { + visual: boolean; + collision: boolean; + joints: boolean; + jointNames: boolean; + linkNames: boolean; +} + +// Appearance settings +export interface AppearanceConfig { + colors: { + collision: string; + joint: string; + link: string; + jointIndicator: string; + highlight: string; + background: string; + }; + opacity: { + visual: number; + collision: number; + link: number; + }; +} + +// Editor configuration +export interface EditorConfig { + isEditMode: boolean; + currentTool: TransformTool; + snap: { + translation: number; + scale: number; + rotation: number; + }; +} + +// View configuration +export interface ViewConfig { + zoom: { + current: number; + initial: number; + }; + nameHeight: number; +} + +// Main URDF state interface +export interface UrdfState extends SelectionState { + robot?: IUrdfRobot; + jointStates: JointStates; + visibility: VisibilityConfig; + appearance: AppearanceConfig; + editor: EditorConfig; + view: ViewConfig; +} + +// Create the reactive state +export const urdfState = $state({ + // Selection + selectedLink: undefined, + selectedJoint: undefined, + + // Robot data + robot: undefined, + jointStates: { + continuous: {}, + revolute: {}, + }, + + // Visibility settings + visibility: { + visual: true, + collision: false, + joints: true, + jointNames: true, + linkNames: true, + }, + + // Appearance settings + appearance: { + colors: { + collision: URDF_COLORS.COLLISION, + joint: URDF_COLORS.JOINT, + link: URDF_COLORS.LINK, + jointIndicator: URDF_COLORS.JOINT_INDICATOR, + highlight: URDF_COLORS.HIGHLIGHT, + background: URDF_COLORS.BACKGROUND, + }, + opacity: { + visual: 1.0, + collision: 0.7, + link: 1.0, + }, + }, + + // Editor configuration + editor: { + isEditMode: false, + currentTool: 'translate', + snap: { + translation: 0.001, + scale: 0.001, + rotation: 1, + }, + }, + + // View configuration + view: { + zoom: { + current: 1.3, + initial: 1.3, + }, + nameHeight: 0.05, + }, +}); \ No newline at end of file diff --git a/src/lib/components/scene/robot/URDF/utils/UrdfParser.ts b/src/lib/components/scene/robot/URDF/utils/UrdfParser.ts new file mode 100644 index 0000000000000000000000000000000000000000..386e0b2e96a05dd29ff319cde8e8fad0d42158c0 --- /dev/null +++ b/src/lib/components/scene/robot/URDF/utils/UrdfParser.ts @@ -0,0 +1,622 @@ +// create interfaceโ€instances of the model based on XMLโ€URDF file. +// These interfaces are closer to the Three.js structure so it's easy to visualize. + +import type { IUrdfVisual } from "../interfaces/IUrdfVisual"; +import { xyzFromString, rpyFromString, rgbaFromString } from "./helper"; +import type IUrdfLink from "../interfaces/IUrdfLink"; +import type IUrdfJoint from "../interfaces/IUrdfJoint"; +import type IUrdfMesh from "../interfaces/IUrdfMesh"; +import type IUrdfRobot from "../interfaces/IUrdfRobot"; + +/** + * Find all "root" links of a robot. A link is considered a root if + * no joint in the robot references it as a "child". In other words, + * it has no parent joint. + * + * @param robot - The parsed IUrdfRobot object whose links and joints we examine + * @returns An array of IUrdfLink objects that have no parent joint (i.e. root links) + */ +export function getRootLinks(robot: IUrdfRobot): IUrdfLink[] { + const links: IUrdfLink[] = []; + const joints = robot.joints; + + for (const link of Object.values(robot.links)) { + let isRoot = true; + for (const joint of joints) { + if (joint.child.name === link.name) { + isRoot = false; + break; + } + } + if (isRoot) { + links.push(link); + } + } + + return links; +} + +/** + * Find all "root" joints of a robot. A joint is considered a root if + * its parent link is never used as a "child link" anywhere else. + * + * For example, if Joint A's parent is "Base" and no other joint has + * child="Base", then Joint A is a root joint. + * + * @param robot - The parsed IUrdfRobot object + * @returns An array of IUrdfJoint objects with no parent joint (i.e. root joints) + */ +export function getRootJoints(robot: IUrdfRobot): IUrdfJoint[] { + const joints = robot.joints; + const rootJoints: IUrdfJoint[] = []; + + for (const joint of joints) { + let isRoot = true; + + // If any other joint's child matches this joint's parent, then this joint isn't root + for (const parentJoint of joints) { + if (joint.parent.name === parentJoint.child.name) { + isRoot = false; + break; + } + } + + if (isRoot) { + rootJoints.push(joint); + } + } + + return rootJoints; +} + +/** + * Given a parent link, find all joints in the robot that use that link as their parent. + * + * @param robot - The parsed IUrdfRobot object + * @param parent - An IUrdfLink object to use as the "parent" in comparison + * @returns A list of IUrdfJoint objects whose parent.name matches parent.name + */ +export function getChildJoints( + robot: IUrdfRobot, + parent: IUrdfLink +): IUrdfJoint[] { + const childJoints: IUrdfJoint[] = []; + const joints = robot.joints; + if (!joints) { + return []; + } + + for (const joint of joints) { + if (joint.parent.name === parent.name) { + childJoints.push(joint); + } + } + + return childJoints; +} + +/** + * Update the element's attributes (xyz and rpy) in the XML + * for either a joint or a visual element, based on the object's current origin_xyz/origin_rpy. + * + * @param posable - Either an IUrdfJoint or an IUrdfVisual whose `.elem` has an child + */ +export function updateOrigin(posable: IUrdfJoint | IUrdfVisual) { + const origin = posable.elem.getElementsByTagName("origin")[0]; + origin.setAttribute("xyz", posable.origin_xyz.join(" ")); + origin.setAttribute("rpy", posable.origin_rpy.join(" ")); +} + +/** + * Main URDF parser class. Given a URDF filename (or XML string), it will: + * 1) Fetch the URDF text (if given a URL/filename) + * 2) Parse materials/colors + * 3) Parse links (including visual & collision) + * 4) Parse joints + * 5) Build an IUrdfRobot data structure that is easier to traverse in JS/Three.js + */ +export class UrdfParser { + filename: string; + prefix: string; // e.g. "robots/so_arm100/" + colors: { [name: string]: [number, number, number, number] } = {}; + robot: IUrdfRobot = { name: "", links: {}, joints: [] }; + + /** + * @param filename - Path or URL to the URDF file (XML). May be relative. + * @param prefix - A folder prefix used when resolving "package://" or relative mesh paths. + */ + constructor(filename: string, prefix: string = "") { + this.filename = filename; + // Ensure prefix ends with exactly one slash + this.prefix = prefix.endsWith("/") ? prefix : prefix + "/"; + } + + /** + * Fetch the URDF file from `this.filename` and return its text. + * @returns A promise that resolves to the raw URDF XML string. + */ + async load(): Promise { + return fetch(this.filename).then((res) => res.text()); + } + + /** + * Clear any previously parsed robot data, preparing for a fresh parse. + */ + reset() { + this.robot = { name: "", links: {}, joints: [] }; + } + + /** + * Parse a URDF XML string and produce an IUrdfRobot object. + * + * @param data - A string containing valid URDF XML. + * @returns The fully populated IUrdfRobot, including colors, links, and joints. + * @throws If the root element is not . + */ + fromString(data: string): IUrdfRobot { + this.reset(); + const dom = new window.DOMParser().parseFromString(data, "text/xml"); + this.robot.elem = dom.documentElement; + return this.parseRobotXMLNode(dom.documentElement); + } + + /** + * Internal helper: ensure the root node is , then parse its children. + * + * @param robotNode - The Element from the DOMParser. + * @returns The populated IUrdfRobot data structure. + * @throws If robotNode.nodeName !== "robot" + */ + private parseRobotXMLNode(robotNode: Element): IUrdfRobot { + if (robotNode.nodeName !== "robot") { + throw new Error(`Invalid URDF: no (found <${robotNode.nodeName}>)`); + } + + this.robot.name = robotNode.getAttribute("name") || ""; + this.parseColorsFromRobot(robotNode); + this.parseLinks(robotNode); + this.parseJoints(robotNode); + return this.robot; + } + + /** + * Look at all tags under and store their names โ†’ RGBA values. + * + * @param robotNode - The Element. + */ + private parseColorsFromRobot(robotNode: Element) { + const xmlMaterials = robotNode.getElementsByTagName("material"); + for (let i = 0; i < xmlMaterials.length; i++) { + const matNode = xmlMaterials[i]; + if (!matNode.hasAttribute("name")) { + console.warn("Found with no name attribute"); + continue; + } + const name = matNode.getAttribute("name")!; + const colorTags = matNode.getElementsByTagName("color"); + if (colorTags.length === 0) continue; + + const colorElem = colorTags[0]; + if (!colorElem.hasAttribute("rgba")) continue; + + // e.g. "0.06 0.4 0.1 1.0" + const rgba = rgbaFromString(colorElem) || [0, 0, 0, 1]; + this.colors[name] = rgba; + } + } + + /** + * Parse every under and build an IUrdfLink entry containing: + * - name + * - arrays of IUrdfVisual for tags + * - arrays of IUrdfVisual for tags + * - a pointer to its original XML Element (elem) + * + * @param robotNode - The Element. + */ + private parseLinks(robotNode: Element) { + const xmlLinks = robotNode.getElementsByTagName("link"); + for (let i = 0; i < xmlLinks.length; i++) { + const linkXml = xmlLinks[i]; + if (!linkXml.hasAttribute("name")) { + console.error("Link without a name:", linkXml); + continue; + } + const linkName = linkXml.getAttribute("name")!; + + const linkObj: IUrdfLink = { + name: linkName, + visual: [], + collision: [], + elem: linkXml, + }; + this.robot.links[linkName] = linkObj; + + // Parse all children + const visualXmls = linkXml.getElementsByTagName("visual"); + for (let j = 0; j < visualXmls.length; j++) { + linkObj.visual.push(this.parseVisual(visualXmls[j])); + } + + // Parse all children (reuse parseVisual; color is ignored later) + const collXmls = linkXml.getElementsByTagName("collision"); + for (let j = 0; j < collXmls.length; j++) { + linkObj.collision.push(this.parseVisual(collXmls[j])); + } + } + } + + /** + * Parse a or element into an IUrdfVisual. Reads: + * - (calls parseGeometry to extract mesh, cylinder, box, etc.) + * - (xyz, rpy) + * - (either embedded or named reference) + * + * @param node - The or Element. + * @returns A fully populated IUrdfVisual object. + */ + private parseVisual(node: Element): IUrdfVisual { + const visual: Partial = { elem: node }; + + for (let i = 0; i < node.childNodes.length; i++) { + const child = node.childNodes[i]; + + // Skip non-element nodes (like text nodes containing whitespace) + if (child.nodeType !== Node.ELEMENT_NODE) { + continue; + } + + const childElement = child as Element; + switch (childElement.nodeName) { + case "geometry": { + this.parseGeometry(childElement, visual); + break; + } + case "origin": { + const pos = xyzFromString(childElement); + const rpy = rpyFromString(childElement); + if (pos) visual.origin_xyz = pos; + if (rpy) visual.origin_rpy = rpy; + break; + } + case "material": { + const cols = childElement.getElementsByTagName("color"); + if (cols.length > 0 && cols[0].hasAttribute("rgba")) { + // Inline color specification + visual.color_rgba = rgbaFromString(cols[0])!; + } else if (childElement.hasAttribute("name")) { + // Named material โ†’ look up previously parsed RGBA + const nm = childElement.getAttribute("name")!; + visual.color_rgba = this.colors[nm]; + } + break; + } + default: { + console.warn("Unknown child node:", childElement.nodeName); + break; + } + } + } + + return visual as IUrdfVisual; + } + + /** + * Parse a element inside or . + * Currently only supports . If you need or , + * you can extend this function similarly. + * + * @param node - The Element. + * @param visual - A partial IUrdfVisual object to populate + */ + private parseGeometry(node: Element, visual: Partial) { + for (let i = 0; i < node.childNodes.length; i++) { + const child = node.childNodes[i]; + + // Skip non-element nodes (like text nodes containing whitespace) + if (child.nodeType !== Node.ELEMENT_NODE) { + continue; + } + + const childElement = child as Element; + if (childElement.nodeName === "mesh") { + const rawFilename = childElement.getAttribute("filename"); + if (!rawFilename) { + console.warn(" missing filename!"); + return; + } + + // 1) Resolve the URL (handles "package://" or relative paths) + const resolvedUrl = this.resolveFilename(rawFilename); + + // 2) Parse optional scale (e.g. "1 1 1") + let scale: [number, number, number] = [1, 1, 1]; + if (childElement.hasAttribute("scale")) { + const parts = childElement.getAttribute("scale")!.split(" ").map(parseFloat); + if (parts.length === 3) { + scale = [parts[0], parts[1], parts[2]]; + } + } + + // 3) Deduce mesh type from file extension + const ext = resolvedUrl.slice(resolvedUrl.lastIndexOf(".") + 1).toLowerCase(); + let type: "stl" | "fbx" | "obj" | "dae"; + switch (ext) { + case "stl": + type = "stl"; + break; + case "fbx": + type = "fbx"; + break; + case "obj": + type = "obj"; + break; + case "dae": + type = "dae"; + break; + default: + throw new Error("Unknown mesh extension: " + ext); + } + + visual.geometry = { filename: resolvedUrl, type, scale } as IUrdfMesh; + visual.type = "mesh"; + return; + } + + // If you also want or , copy your previous logic here: + // e.g. if (childElement.nodeName === "cylinder") { โ€ฆ } + } + } + + /** + * Transform a URIโ€like string into an actual URL. Handles: + * 1) http(s):// or data: โ†’ leave unchanged + * 2) package://some_package/... โ†’ replace with prefix + "some_package/... + * 3) package:/some_package/... โ†’ same as above + * 4) Anything else (e.g. "meshes/Foo.stl") is treated as relative. + * + * @param raw - The raw filename from URDF (e.g. "meshes/Base.stl" or "package://my_pkg/mesh.dae") + * @returns The fully resolved URL string + */ + private resolveFilename(raw: string): string { + // 1) absolute http(s) or data URIs + if (/^https?:\/\//.test(raw) || raw.startsWith("data:")) { + return raw; + } + + // 2) package://some_package/โ€ฆ + if (raw.startsWith("package://")) { + const rel = raw.substring("package://".length); + return this.joinUrl(this.prefix, rel); + } + + // 3) package:/some_package/โ€ฆ + if (raw.startsWith("package:/")) { + const rel = raw.substring("package:/".length); + return this.joinUrl(this.prefix, rel); + } + + // 4) anything else (e.g. "meshes/Foo.stl") is treated as relative + return this.joinUrl(this.prefix, raw); + } + + /** + * Helper to join a base URL with a relative path, ensuring exactly one '/' in between + * + * @param base - e.g. "/robots/so_arm100/" + * @param rel - e.g. "meshes/Base.stl" (with or without a leading slash) + * @returns A string like "/robots/so_arm100/meshes/Base.stl" + */ + private joinUrl(base: string, rel: string): string { + if (!base.startsWith("/")) base = "/" + base; + if (!base.endsWith("/")) base = base + "/"; + if (rel.startsWith("/")) rel = rel.substring(1); + return base + rel; + } + + /** + * Parse every under and build an IUrdfJoint entry. For each joint: + * 1) parent link (lookup in `this.robot.links[parentName]`) + * 2) child link (lookup in `this.robot.links[childName]`) + * 3) origin: xyz + rpy + * 4) axis (default [0,0,1] if absent) + * 5) limit (if present, lower/upper/effort/velocity) + * + * @param robotNode - The Element. + * @throws If a joint references a link name that doesn't exist. + */ + private parseJoints(robotNode: Element) { + const links = this.robot.links; + const joints: IUrdfJoint[] = []; + this.robot.joints = joints; + + const xmlJoints = robotNode.getElementsByTagName("joint"); + for (let i = 0; i < xmlJoints.length; i++) { + const jointXml = xmlJoints[i]; + const parentElems = jointXml.getElementsByTagName("parent"); + const childElems = jointXml.getElementsByTagName("child"); + if (parentElems.length !== 1 || childElems.length !== 1) { + console.warn("Joint without exactly one or :", jointXml); + continue; + } + + const parentName = parentElems[0].getAttribute("link")!; + const childName = childElems[0].getAttribute("link")!; + + const parentLink = links[parentName]; + const childLink = links[childName]; + if (!parentLink || !childLink) { + throw new Error( + `Joint references missing link: ${parentName} or ${childName}` + ); + } + + // Default origin and rpy + let xyz: [number, number, number] = [0, 0, 0]; + let rpy: [number, number, number] = [0, 0, 0]; + const originTags = jointXml.getElementsByTagName("origin"); + if (originTags.length === 1) { + xyz = xyzFromString(originTags[0]) || xyz; + rpy = rpyFromString(originTags[0]) || rpy; + } + + // Default axis + let axis: [number, number, number] = [0, 0, 1]; + const axisTags = jointXml.getElementsByTagName("axis"); + if (axisTags.length === 1) { + axis = xyzFromString(axisTags[0]) || axis; + } + + // Optional limit + let limit; + const limitTags = jointXml.getElementsByTagName("limit"); + if (limitTags.length === 1) { + const lim = limitTags[0]; + limit = { + lower: parseFloat(lim.getAttribute("lower") || "0"), + upper: parseFloat(lim.getAttribute("upper") || "0"), + effort: parseFloat(lim.getAttribute("effort") || "0"), + velocity: parseFloat(lim.getAttribute("velocity") || "0"), + }; + } + + joints.push({ + name: jointXml.getAttribute("name") || undefined, + type: jointXml.getAttribute("type") as "revolute" | "continuous" | "prismatic" | "fixed" | "floating" | "planar", + origin_xyz: xyz, + origin_rpy: rpy, + axis_xyz: axis, + rotation: [0, 0, 0], + parent: parentLink, + child: childLink, + limit: limit, + elem: jointXml, + }); + } + } + + /** + * If you ever want to reโ€serialize the robot back to URDF XML, + * this method returns the stringified root element. + * + * @returns A string beginning with '' followed by the current XML. + */ + getURDFXML(): string { + return this.robot.elem + ? '\n' + this.robot.elem.outerHTML + : ""; + } +} + + +/** + * ============================================================================== + * Example of how the parsed data (IUrdfRobot) maps from the URDF XML ("so_arm100"): + * + * { + * // The name attribute + * name: "so_arm100", + * + * // Materials/colors parsed from tags + * colors: { + * "green": [0.06, 0.4, 0.1, 1.0], + * "black": [0.1, 0.1, 0.1, 1.0] + * }, + * + * // Each under becomes an entry in `links` + * links: { + * "Base": { + * name: "Base", + * + * // Array of visuals: each inside + * visual: [ + * { + * elem: // the Element object for Base, + * type: "mesh", + * geometry: { + * filename: "/robots/so_arm100/meshes/Base.stl", + * type: "stl", + * scale: [1, 1, 1] + * }, + * origin_xyz: [0, 0, 0], // default since no in visual + * origin_rpy: [0, 0, 0], // default since no in visual + * color_rgba: [0.06, 0.4, 0.1, 1.0] // matches + * }, + * { + * elem: // the second Element for Base, + * type: "mesh", + * geometry: { + * filename: "/robots/so_arm100/meshes/Base_Motor.stl", + * type: "stl", + * scale: [1, 1, 1] + * }, + * origin_xyz: [0, 0, 0], + * origin_rpy: [0, 0, 0], + * color_rgba: [0.1, 0.1, 0.1, 1.0] // matches + * } + * ], + * + * // Array of collisions: each inside + * collision: [ + * { + * elem: // the Element for Base, + * type: "mesh", + * geometry: { + * filename: "/robots/so_arm100/meshes/Base.stl", + * type: "stl", + * scale: [1, 1, 1] + * }, + * origin_xyz: [0, 0, 0], + * origin_rpy: [0, 0, 0] + * // no color for collisions + * } + * ] + * }, + * + * // ... other links (e.g. "Rotation_Pitch", "Upper_Arm", etc.) follow the same structure + * }, + * + * // Each under becomes an entry in `joints` array + * joints: [ + * { + * name: "Rotation", + * type: "revolute", + * origin_xyz: [0, -0.0452, 0.0165], + * origin_rpy: [1.57079, 0, 0], + * axis_xyz: [0, -1, 0], + * rotation: [0, 0, 0], // runtime placeholder + * parent: // reference to links["Base"], + * child: // reference to links["Rotation_Pitch"], + * limit: { + * lower: -2, + * upper: 2, + * effort: 35, + * velocity: 1 + * }, + * elem: // the Element object + * }, + * + * { + * name: "Pitch", + * type: "revolute", + * origin_xyz: [0, 0.1025, 0.0306], + * origin_rpy: [-1.8, 0, 0], + * axis_xyz: [1, 0, 0], + * rotation: [0, 0, 0], + * parent: // reference to links["Rotation_Pitch"], + * child: // reference to links["Upper_Arm"], + * limit: { + * lower: 0, + * upper: 3.5, + * effort: 35, + * velocity: 1 + * }, + * elem: // the Element object + * }, + * + * // ... additional joints ("Elbow", "Wrist_Pitch", "Wrist_Roll", "Jaw") follow similarly + * ] + * } + * + * ============================================================================== + */ diff --git a/src/lib/components/scene/robot/URDF/utils/helper.ts b/src/lib/components/scene/robot/URDF/utils/helper.ts new file mode 100644 index 0000000000000000000000000000000000000000..43640b8bd8c717620ba04c1c8da32175d630d1f4 --- /dev/null +++ b/src/lib/components/scene/robot/URDF/utils/helper.ts @@ -0,0 +1,50 @@ +export function xyzFromString(child: Element): [x: number, y: number, z: number] | undefined { + const arr = numberStringToArray(child, 'xyz'); + if (!arr || arr.length != 3) { + return + } + return arr as [x: number, y: number, z: number]; +} + +export function rpyFromString(child: Element): [roll: number, pitch: number, yaw: number] | undefined { + const arr = numberStringToArray(child, 'rpy'); + if (!arr || arr.length != 3) { + return + } + return arr as [roll: number, pitch: number, yaw: number]; +} + +export function rgbaFromString(child: Element): [r: number, g: number, b: number, a: number] | undefined { + const arr = numberStringToArray(child, 'rgba'); + if (!arr || arr.length != 4) { + return + } + return arr as [r: number, g: number, b: number, a: number]; +} + +export function numberStringToArray( + child: Element, name: string = 'xyz'): number[] | undefined { + // parse a list of values from a string + // (like "1.0 2.2 3.0" into an array like [1, 2.2, 3]) + // used in URDF for position, orientation an color values + if (child.hasAttribute(name)) { + const xyzStr = child.getAttribute(name)?.split(' ') + if (xyzStr) { + const arr = [] + for (const nr of xyzStr) { + arr.push(parseFloat(nr)); + } + return arr; + } + } +} + +export function radToEuler(rad: number): number { + return rad * 180 / Math.PI +} + +export function numberArrayToColor([r, g, b]: [number, number, number]): string { + const toHex = (n: number) => Math.round(n).toString(16).padStart(2, '0'); + // 0.06, 0.4, 0.1, 1 + return `#${toHex(r * 255)}${toHex(g * 255)}${toHex(b * 255)}`; +} diff --git a/src/lib/configs/performanceConfig.ts b/src/lib/configs/performanceConfig.ts new file mode 100644 index 0000000000000000000000000000000000000000..06074cd4bb5e0fb521d692040adb2853a8ee0ab9 --- /dev/null +++ b/src/lib/configs/performanceConfig.ts @@ -0,0 +1,204 @@ +/** + * High-Performance Configuration for Robot Control + * Optimized for minimum latency and maximum responsiveness + * + * These values are hardcoded constants optimized for local network operations. + * Modify these values carefully - aggressive settings may cause instability. + */ +export const PERFORMANCE_CONFIG = { + /** + * WebSocket Connection Settings + * Optimized for minimal connection overhead + */ + WEBSOCKET: { + // Connection timeout - reduced from default 10s to 3s + CONNECTION_TIMEOUT_MS: 3000, + + // Reconnection settings - faster recovery + MAX_RECONNECT_ATTEMPTS: 10, + INITIAL_RECONNECT_DELAY_MS: 250, // Start with 250ms instead of 1s + MAX_RECONNECT_DELAY_MS: 2000, // Cap at 2s instead of exponential growth + + // Heartbeat frequency - more frequent to detect issues faster + HEARTBEAT_INTERVAL_MS: 5000, // Every 5s instead of 30s + + }, + + /** + * Robot Polling and Update Rates + * Optimized for real-time responsiveness + */ + ROBOT_POLLING: { + // USB Master polling - increased from 200ms to 50ms (20Hz) + USB_MASTER_POLL_INTERVAL_MS: 50, + + // Robot sync interval - increased from 100ms to 33ms (~30Hz) + ROBOT_SYNC_INTERVAL_MS: 33, + + // State update frequency for mock/slave drivers + STATE_UPDATE_INTERVAL_MS: 33, // ~30Hz instead of 10Hz + + // Command sequence playback rate + SEQUENCE_PLAYBACK_INTERVAL_MS: 16, // ~60Hz for smooth playback + + // Minimum position change threshold (servo units) + POSITION_CHANGE_THRESHOLD: 2, // Reduced from 5 for more sensitivity + }, + + /** + * Data Processing Optimizations + * Reduce computational overhead + */ + DATA_PROCESSING: { + // Joint smoothing history size - reduced for lower latency + SMOOTHING_HISTORY_SIZE: 2, // Reduced from 3 + + // Enable/disable smoothing by default (can cause latency) + ENABLE_SMOOTHING: false, // Disabled for minimum latency + }, + + /** + * Network and Serial Communication + * Optimized for low-latency communication + */ + COMMUNICATION: { + // Serial port settings for USB connections + USB_BAUD_RATE: 1000000, // 1Mbps - maximum for most servos + + BATCH_COMMAND_DELAY_MS: 10, // Minimal delay between batch commands + }, + + /** + * Safety and Control Optimizations (inspired by lerobot) + * Prevent dangerous movements and ensure smooth operation + */ + SAFETY: { + // Maximum relative movement per step (degrees) + MAX_RELATIVE_TARGET_DEG: 15, // Prevent sudden large movements + + // Maximum joint velocity (degrees/second) + MAX_JOINT_VELOCITY_DEG_S: 120, // Limit movement speed + + // Enable position clamping for safety + ENABLE_POSITION_CLAMPING: true, + + // Emergency stop on large position jumps + EMERGENCY_STOP_THRESHOLD_DEG: 45, + }, + + /** + * Timing and Synchronization (inspired by lerobot) + * Precise timing control for smooth operation + */ + TIMING: { + // Use busy wait for precise timing (like lerobot) + USE_BUSY_WAIT: true, + + // Enable high-precision timing + USE_HIGH_PRECISION_TIMING: true, + + // Warmup time for device synchronization + DEVICE_WARMUP_TIME_MS: 2000, + }, + + /** + * Logging and Debug Settings + * Minimal logging for production performance + */ + LOGGING: { + // Enable performance logging + ENABLE_PERFORMANCE_LOGS: false, + + // Log level for WebSocket traffic + WEBSOCKET_LOG_LEVEL: 'error', // Only log errors + + // Enable timing measurements (like lerobot) + ENABLE_TIMING_MEASUREMENTS: true, + + // Console log buffer size + LOG_BUFFER_SIZE: 100, + + // Enable detailed performance metrics + ENABLE_DETAILED_METRICS: false, + } +} as const; + +/** + * Performance Presets + * Quick configurations for different scenarios + */ +export const PERFORMANCE_PRESETS = { + /** + * Ultra Low Latency - For same-network, high-performance scenarios + * Maximum responsiveness, minimal buffering + */ + ULTRA_LOW_LATENCY: { + ...PERFORMANCE_CONFIG, + ROBOT_POLLING: { + ...PERFORMANCE_CONFIG.ROBOT_POLLING, + USB_MASTER_POLL_INTERVAL_MS: 20, // 50Hz + ROBOT_SYNC_INTERVAL_MS: 16, // ~60Hz + STATE_UPDATE_INTERVAL_MS: 16, // ~60Hz + }, + WEBSOCKET: { + ...PERFORMANCE_CONFIG.WEBSOCKET, + HEARTBEAT_INTERVAL_MS: 2000, // Every 2s + CONNECTION_TIMEOUT_MS: 1500, // 1.5s timeout + }, + DATA_PROCESSING: { + ...PERFORMANCE_CONFIG.DATA_PROCESSING, + ENABLE_SMOOTHING: false, // No smoothing + POSITION_CHANGE_THRESHOLD: 1, // Maximum sensitivity + } + }, + + /** + * Balanced - Good performance with stability + * Recommended for most use cases + */ + BALANCED: { + ...PERFORMANCE_CONFIG, + DATA_PROCESSING: { + ...PERFORMANCE_CONFIG.DATA_PROCESSING, + ENABLE_SMOOTHING: true, + SMOOTHING_HISTORY_SIZE: 3, + } + }, + + /** + * Stable - Conservative settings for unreliable networks + * Prioritizes stability over latency + */ + STABLE: { + ...PERFORMANCE_CONFIG, + ROBOT_POLLING: { + ...PERFORMANCE_CONFIG.ROBOT_POLLING, + USB_MASTER_POLL_INTERVAL_MS: 100, // 10Hz + ROBOT_SYNC_INTERVAL_MS: 100, // 10Hz + }, + WEBSOCKET: { + ...PERFORMANCE_CONFIG.WEBSOCKET, + HEARTBEAT_INTERVAL_MS: 10000, // Every 10s + CONNECTION_TIMEOUT_MS: 5000, // 5s timeout + } + } +} as const; + +/** + * Active configuration - change this to switch presets + * ULTRA_LOW_LATENCY: Maximum performance for local networks + * BALANCED: Good compromise between speed and stability + * STABLE: Conservative settings for unreliable connections + */ +export const ACTIVE_PERFORMANCE_CONFIG = PERFORMANCE_PRESETS.ULTRA_LOW_LATENCY; + +/** + * Utility functions for configuration access + */ +export const getWebSocketConfig = () => ACTIVE_PERFORMANCE_CONFIG.WEBSOCKET; +export const getRobotPollingConfig = () => ACTIVE_PERFORMANCE_CONFIG.ROBOT_POLLING; +export const getDataProcessingConfig = () => ACTIVE_PERFORMANCE_CONFIG.DATA_PROCESSING; +export const getCommunicationConfig = () => ACTIVE_PERFORMANCE_CONFIG.COMMUNICATION; +export const getSafetyConfig = () => ACTIVE_PERFORMANCE_CONFIG.SAFETY; +export const getTimingConfig = () => ACTIVE_PERFORMANCE_CONFIG.TIMING; +export const getLoggingConfig = () => ACTIVE_PERFORMANCE_CONFIG.LOGGING; \ No newline at end of file diff --git a/src/lib/configs/robotUrdfConfig.ts b/src/lib/configs/robotUrdfConfig.ts new file mode 100644 index 0000000000000000000000000000000000000000..ae170896263f3437c2a5adf2d5bf6955677630d2 --- /dev/null +++ b/src/lib/configs/robotUrdfConfig.ts @@ -0,0 +1,54 @@ +import type { RobotUrdfConfig } from "$lib/types/urdf"; + +export const robotUrdfConfigMap: { [key: string]: RobotUrdfConfig } = { + "so-arm100": { + urdfUrl: "/robots/so-100/so_arm100.urdf", + jointNameIdMap: { + Rotation: 1, + Pitch: 2, + Elbow: 3, + Wrist_Pitch: 4, + Wrist_Roll: 5, + Jaw: 6, + }, + // Rest position - robot in neutral/calibration pose (all joints at 0 degrees) + restPosition: { + Rotation: 0, + Pitch: 0, + Elbow: 0, + Wrist_Pitch: 0, + Wrist_Roll: 0, + Jaw: 0, + }, + compoundMovements: [ + { + name: "Jaw down & up", + primaryJoint: 2, + primaryFormula: "primary < 100 ? 1 : -1", + dependents: [ + { + joint: 3, + formula: "primary < 100 ? -1.9 * deltaPrimary : 0.4 * deltaPrimary", + // formula: "- deltaPrimary * (0.13 * Math.sin(primary * (Math.PI / 180)) + 0.13 * Math.sin((primary-dependent) * (Math.PI / 180)))/(0.13 * Math.sin((primary - dependent) * (Math.PI / 180)))", + }, + { + joint: 4, + formula: + "primary < 100 ? (primary < 10 ? 0 : 0.51 * deltaPrimary) : -0.4 * deltaPrimary", + }, + ], + }, + { + name: "Jaw backward & forward", + primaryJoint: 2, + primaryFormula: "1", + dependents: [ + { + joint: 3, + formula: "-0.9*deltaPrimary", + }, + ], + }, + ] + } +}; \ No newline at end of file diff --git a/src/lib/configs/settings.svelte.ts b/src/lib/configs/settings.svelte.ts new file mode 100644 index 0000000000000000000000000000000000000000..116b42c7e2395db276fed5b14a3d3f4642e62c88 --- /dev/null +++ b/src/lib/configs/settings.svelte.ts @@ -0,0 +1,8 @@ +import type { Settings } from "$lib/types/settings"; + +export const settings: Settings = $state({ + camera: { + position: [0, 0, 0], + fov: 75, + }, +}); \ No newline at end of file diff --git a/src/lib/feetech.js/README.md b/src/lib/feetech.js/README.md new file mode 100644 index 0000000000000000000000000000000000000000..95c9a66168ff8149eadcbab0ad39e92ed74d7034 --- /dev/null +++ b/src/lib/feetech.js/README.md @@ -0,0 +1,26 @@ +# feetech.js + +Control feetech servos through browser + +## Usage + +```bash +# Install the package +npm install feetech.js +``` + +```javascript +import { scsServoSDK } from 'feetech.js'; + +await scsServoSDK.connect(); + +const position = await scsServoSDK.readPosition(1); +console.log(position); // 1122 +``` + +## Example usage: + +- simple example: [test.html](./test.html) +- the bambot website: [bambot.org](https://bambot.org) + + diff --git a/src/lib/feetech.js/index.d.ts b/src/lib/feetech.js/index.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..5634957b02837d2ece5d20d68ea0ae34a0d3432b --- /dev/null +++ b/src/lib/feetech.js/index.d.ts @@ -0,0 +1,28 @@ +export type ConnectionOptions = { + baudRate?: number; + protocolEnd?: number; +}; + +export type ServoPositions = Map | Record; +export type ServoSpeeds = Map | Record; // New type alias for speeds + +export interface ScsServoSDK { + connect(options?: ConnectionOptions): Promise; + disconnect(): Promise; + readPosition(servoId: number): Promise; + readBaudRate(servoId: number): Promise; + readMode(servoId: number): Promise; + writePosition(servoId: number, position: number): Promise<"success">; + writeTorqueEnable(servoId: number, enable: boolean): Promise<"success">; + writeAcceleration(servoId: number, acceleration: number): Promise<"success">; + setWheelMode(servoId: number): Promise<"success">; + setPositionMode(servoId: number): Promise<"success">; + writeWheelSpeed(servoId: number, speed: number): Promise<"success">; + syncReadPositions(servoIds: number[]): Promise>; + syncWritePositions(servoPositions: ServoPositions): Promise<"success">; + syncWriteWheelSpeed(servoSpeeds: ServoSpeeds): Promise<"success">; // Add syncWriteWheelSpeed definition + setBaudRate(servoId: number, baudRateIndex: number): Promise<"success">; + setServoId(currentServoId: number, newServoId: number): Promise<"success">; +} + +export const scsServoSDK: ScsServoSDK; diff --git a/src/lib/feetech.js/index.mjs b/src/lib/feetech.js/index.mjs new file mode 100644 index 0000000000000000000000000000000000000000..ade4f53b23444b69819c7ce7d62ca28f2ff5bd93 --- /dev/null +++ b/src/lib/feetech.js/index.mjs @@ -0,0 +1,43 @@ +// Import all functions from the new scsServoSDK module +import { + connect, + disconnect, + readPosition, + readBaudRate, + readMode, + writePosition, + writeTorqueEnable, + writeAcceleration, + setWheelMode, + setPositionMode, + writeWheelSpeed, + syncReadPositions, + syncWritePositions, + syncWriteWheelSpeed, // Import the new function + setBaudRate, + setServoId, +} from "./scsServoSDK.mjs"; + +// Create an object to hold all the SCS servo functions +export const scsServoSDK = { + connect, + disconnect, + readPosition, + readBaudRate, + readMode, + writePosition, + writeTorqueEnable, + writeAcceleration, + setWheelMode, + setPositionMode, + writeWheelSpeed, + syncReadPositions, + syncWritePositions, + syncWriteWheelSpeed, // Add the new function to the export + setBaudRate, + setServoId, +}; + +// Future: You can add exports for other servo types here, e.g.: +// export { stsServoSDK } from './stsServoSDK.mjs'; +// export { smsServoSDK } from './smsServoSDK.mjs'; diff --git a/src/lib/feetech.js/lowLevelSDK.mjs b/src/lib/feetech.js/lowLevelSDK.mjs new file mode 100644 index 0000000000000000000000000000000000000000..878880fa8fd8d9af85e4527ee01387cd9d44fcf6 --- /dev/null +++ b/src/lib/feetech.js/lowLevelSDK.mjs @@ -0,0 +1,1184 @@ +// Constants +export const BROADCAST_ID = 0xFE; // 254 +export const MAX_ID = 0xFC; // 252 + +// Protocol instructions +export const INST_PING = 1; +export const INST_READ = 2; +export const INST_WRITE = 3; +export const INST_REG_WRITE = 4; +export const INST_ACTION = 5; +export const INST_SYNC_WRITE = 131; // 0x83 +export const INST_SYNC_READ = 130; // 0x82 +export const INST_STATUS = 85; // 0x55, status packet instruction (0x55) + +// Communication results +export const COMM_SUCCESS = 0; // tx or rx packet communication success +export const COMM_PORT_BUSY = -1; // Port is busy (in use) +export const COMM_TX_FAIL = -2; // Failed transmit instruction packet +export const COMM_RX_FAIL = -3; // Failed get status packet +export const COMM_TX_ERROR = -4; // Incorrect instruction packet +export const COMM_RX_WAITING = -5; // Now receiving status packet +export const COMM_RX_TIMEOUT = -6; // There is no status packet +export const COMM_RX_CORRUPT = -7; // Incorrect status packet +export const COMM_NOT_AVAILABLE = -9; + +// Packet constants +export const TXPACKET_MAX_LEN = 250; +export const RXPACKET_MAX_LEN = 250; + +// Protocol Packet positions +export const PKT_HEADER0 = 0; +export const PKT_HEADER1 = 1; +export const PKT_ID = 2; +export const PKT_LENGTH = 3; +export const PKT_INSTRUCTION = 4; +export const PKT_ERROR = 4; +export const PKT_PARAMETER0 = 5; + +// Protocol Error bits +export const ERRBIT_VOLTAGE = 1; +export const ERRBIT_ANGLE = 2; +export const ERRBIT_OVERHEAT = 4; +export const ERRBIT_OVERELE = 8; +export const ERRBIT_OVERLOAD = 32; + +// Default settings +const DEFAULT_BAUDRATE = 1000000; +const LATENCY_TIMER = 16; + +// Global protocol end state +let SCS_END = 0; // (STS/SMS=0, SCS=1) + +// Utility functions for handling word operations +export function SCS_LOWORD(l) { + return l & 0xFFFF; +} + +export function SCS_HIWORD(l) { + return (l >> 16) & 0xFFFF; +} + +export function SCS_LOBYTE(w) { + if (SCS_END === 0) { + return w & 0xFF; + } else { + return (w >> 8) & 0xFF; + } +} + +export function SCS_HIBYTE(w) { + if (SCS_END === 0) { + return (w >> 8) & 0xFF; + } else { + return w & 0xFF; + } +} + +export function SCS_MAKEWORD(a, b) { + if (SCS_END === 0) { + return (a & 0xFF) | ((b & 0xFF) << 8); + } else { + return (b & 0xFF) | ((a & 0xFF) << 8); + } +} + +export function SCS_MAKEDWORD(a, b) { + return (a & 0xFFFF) | ((b & 0xFFFF) << 16); +} + +export function SCS_TOHOST(a, b) { + if (a & (1 << b)) { + return -(a & ~(1 << b)); + } else { + return a; + } +} + +export class PortHandler { + constructor() { + this.port = null; + this.reader = null; + this.writer = null; + this.isOpen = false; + this.isUsing = false; + this.baudrate = DEFAULT_BAUDRATE; + this.packetStartTime = 0; + this.packetTimeout = 0; + this.txTimePerByte = 0; + } + + async requestPort() { + try { + this.port = await navigator.serial.requestPort(); + return true; + } catch (err) { + console.error('Error requesting serial port:', err); + return false; + } + } + + async openPort() { + if (!this.port) { + return false; + } + + try { + await this.port.open({ baudRate: this.baudrate }); + this.reader = this.port.readable.getReader(); + this.writer = this.port.writable.getWriter(); + this.isOpen = true; + this.txTimePerByte = (1000.0 / this.baudrate) * 10.0; + return true; + } catch (err) { + console.error('Error opening port:', err); + return false; + } + } + + async closePort() { + if (this.reader) { + await this.reader.releaseLock(); + this.reader = null; + } + + if (this.writer) { + await this.writer.releaseLock(); + this.writer = null; + } + + if (this.port && this.isOpen) { + await this.port.close(); + this.isOpen = false; + } + } + + async clearPort() { + if (this.reader) { + await this.reader.releaseLock(); + this.reader = this.port.readable.getReader(); + } + } + + setBaudRate(baudrate) { + this.baudrate = baudrate; + this.txTimePerByte = (1000.0 / this.baudrate) * 10.0; + return true; + } + + getBaudRate() { + return this.baudrate; + } + + async writePort(data) { + if (!this.isOpen || !this.writer) { + return 0; + } + + try { + await this.writer.write(new Uint8Array(data)); + return data.length; + } catch (err) { + console.error('Error writing to port:', err); + return 0; + } + } + + async readPort(length) { + if (!this.isOpen || !this.reader) { + return []; + } + + try { + // Increase timeout for more reliable data reception + const timeoutMs = 500; + let totalBytes = []; + const startTime = performance.now(); + + // Continue reading until we get enough bytes or timeout + while (totalBytes.length < length) { + // Create a timeout promise + const timeoutPromise = new Promise(resolve => { + setTimeout(() => resolve({ value: new Uint8Array(), done: false, timeout: true }), 100); // Short internal timeout + }); + + // Race between reading and timeout + const result = await Promise.race([ + this.reader.read(), + timeoutPromise + ]); + + if (result.timeout) { + // Internal timeout - check if we've exceeded total timeout + if (performance.now() - startTime > timeoutMs) { + console.log(`readPort total timeout after ${timeoutMs}ms`); + break; + } + continue; // Try reading again + } + + if (result.done) { + console.log('Reader done, stream closed'); + break; + } + + if (result.value.length === 0) { + // If there's no data but we haven't timed out yet, wait briefly and try again + await new Promise(resolve => setTimeout(resolve, 10)); + + // Check if we've exceeded total timeout + if (performance.now() - startTime > timeoutMs) { + console.log(`readPort total timeout after ${timeoutMs}ms`); + break; + } + continue; + } + + // Add received bytes to our total + const newData = Array.from(result.value); + totalBytes.push(...newData); + console.log(`Read ${newData.length} bytes:`, newData.map(b => b.toString(16).padStart(2, '0')).join(' ')); + + // If we've got enough data, we can stop + if (totalBytes.length >= length) { + break; + } + } + + return totalBytes; + } catch (err) { + console.error('Error reading from port:', err); + return []; + } + } + + setPacketTimeout(packetLength) { + this.packetStartTime = this.getCurrentTime(); + this.packetTimeout = (this.txTimePerByte * packetLength) + (LATENCY_TIMER * 2.0) + 2.0; + } + + setPacketTimeoutMillis(msec) { + this.packetStartTime = this.getCurrentTime(); + this.packetTimeout = msec; + } + + isPacketTimeout() { + if (this.getTimeSinceStart() > this.packetTimeout) { + this.packetTimeout = 0; + return true; + } + return false; + } + + getCurrentTime() { + return performance.now(); + } + + getTimeSinceStart() { + const timeSince = this.getCurrentTime() - this.packetStartTime; + if (timeSince < 0.0) { + this.packetStartTime = this.getCurrentTime(); + } + return timeSince; + } +} + +export class PacketHandler { + constructor(protocolEnd = 0) { + SCS_END = protocolEnd; + console.log(`PacketHandler initialized with protocol_end=${protocolEnd} (STS/SMS=0, SCS=1)`); + } + + getProtocolVersion() { + return 1.0; + } + + // ่Žทๅ–ๅฝ“ๅ‰ๅ่ฎฎ็ซฏ่ฎพ็ฝฎ็š„ๆ–นๆณ• + getProtocolEnd() { + return SCS_END; + } + + getTxRxResult(result) { + if (result === COMM_SUCCESS) { + return "[TxRxResult] Communication success!"; + } else if (result === COMM_PORT_BUSY) { + return "[TxRxResult] Port is in use!"; + } else if (result === COMM_TX_FAIL) { + return "[TxRxResult] Failed transmit instruction packet!"; + } else if (result === COMM_RX_FAIL) { + return "[TxRxResult] Failed get status packet from device!"; + } else if (result === COMM_TX_ERROR) { + return "[TxRxResult] Incorrect instruction packet!"; + } else if (result === COMM_RX_WAITING) { + return "[TxRxResult] Now receiving status packet!"; + } else if (result === COMM_RX_TIMEOUT) { + return "[TxRxResult] There is no status packet!"; + } else if (result === COMM_RX_CORRUPT) { + return "[TxRxResult] Incorrect status packet!"; + } else if (result === COMM_NOT_AVAILABLE) { + return "[TxRxResult] Protocol does not support this function!"; + } else { + return ""; + } + } + + getRxPacketError(error) { + if (error & ERRBIT_VOLTAGE) { + return "[RxPacketError] Input voltage error!"; + } + if (error & ERRBIT_ANGLE) { + return "[RxPacketError] Angle sen error!"; + } + if (error & ERRBIT_OVERHEAT) { + return "[RxPacketError] Overheat error!"; + } + if (error & ERRBIT_OVERELE) { + return "[RxPacketError] OverEle error!"; + } + if (error & ERRBIT_OVERLOAD) { + return "[RxPacketError] Overload error!"; + } + return ""; + } + + async txPacket(port, txpacket) { + let checksum = 0; + const totalPacketLength = txpacket[PKT_LENGTH] + 4; // 4: HEADER0 HEADER1 ID LENGTH + + if (port.isUsing) { + return COMM_PORT_BUSY; + } + port.isUsing = true; + + // Check max packet length + if (totalPacketLength > TXPACKET_MAX_LEN) { + port.isUsing = false; + return COMM_TX_ERROR; + } + + // Make packet header + txpacket[PKT_HEADER0] = 0xFF; + txpacket[PKT_HEADER1] = 0xFF; + + // Add checksum to packet + for (let idx = 2; idx < totalPacketLength - 1; idx++) { + checksum += txpacket[idx]; + } + + txpacket[totalPacketLength - 1] = (~checksum) & 0xFF; + + // TX packet + await port.clearPort(); + const writtenPacketLength = await port.writePort(txpacket); + if (totalPacketLength !== writtenPacketLength) { + port.isUsing = false; + return COMM_TX_FAIL; + } + + return COMM_SUCCESS; + } + + async rxPacket(port) { + let rxpacket = []; + let result = COMM_RX_FAIL; + + let waitLength = 6; // minimum length (HEADER0 HEADER1 ID LENGTH) + + while (true) { + const data = await port.readPort(waitLength - rxpacket.length); + rxpacket.push(...data); + + if (rxpacket.length >= waitLength) { + // Find packet header + let headerIndex = -1; + for (let i = 0; i < rxpacket.length - 1; i++) { + if (rxpacket[i] === 0xFF && rxpacket[i + 1] === 0xFF) { + headerIndex = i; + break; + } + } + + if (headerIndex === 0) { + // Found at the beginning of the packet + if (rxpacket[PKT_ID] > 0xFD || rxpacket[PKT_LENGTH] > RXPACKET_MAX_LEN) { + // Invalid ID or length + rxpacket.shift(); + continue; + } + + // Recalculate expected packet length + if (waitLength !== (rxpacket[PKT_LENGTH] + PKT_LENGTH + 1)) { + waitLength = rxpacket[PKT_LENGTH] + PKT_LENGTH + 1; + continue; + } + + if (rxpacket.length < waitLength) { + // Check timeout + if (port.isPacketTimeout()) { + result = rxpacket.length === 0 ? COMM_RX_TIMEOUT : COMM_RX_CORRUPT; + break; + } + continue; + } + + // Calculate checksum + let checksum = 0; + for (let i = 2; i < waitLength - 1; i++) { + checksum += rxpacket[i]; + } + checksum = (~checksum) & 0xFF; + + // Verify checksum + if (rxpacket[waitLength - 1] === checksum) { + result = COMM_SUCCESS; + } else { + result = COMM_RX_CORRUPT; + } + break; + } else if (headerIndex > 0) { + // Remove unnecessary bytes before header + rxpacket = rxpacket.slice(headerIndex); + continue; + } + } + + // Check timeout + if (port.isPacketTimeout()) { + result = rxpacket.length === 0 ? COMM_RX_TIMEOUT : COMM_RX_CORRUPT; + break; + } + } + + if (result !== COMM_SUCCESS) { + console.log(`rxPacket result: ${result}, packet: ${rxpacket.map(b => '0x' + b.toString(16).padStart(2,'0')).join(' ')}`); + } else { + console.debug(`rxPacket successful: ${rxpacket.map(b => '0x' + b.toString(16).padStart(2,'0')).join(' ')}`); + } + return [rxpacket, result]; + } + + async txRxPacket(port, txpacket) { + let rxpacket = null; + let error = 0; + let result = COMM_TX_FAIL; + + try { + // Check if port is already in use + if (port.isUsing) { + console.log("Port is busy, cannot start new transaction"); + return [rxpacket, COMM_PORT_BUSY, error]; + } + + // TX packet + console.log("Sending packet:", txpacket.map(b => '0x' + b.toString(16).padStart(2,'0')).join(' ')); + + // Remove retry logic and just send once + result = await this.txPacket(port, txpacket); + console.log(`TX result: ${result}`); + + if (result !== COMM_SUCCESS) { + console.log(`TX failed with result: ${result}`); + port.isUsing = false; // Important: Release the port on TX failure + return [rxpacket, result, error]; + } + + // If ID is broadcast, no need to wait for status packet + if (txpacket[PKT_ID] === BROADCAST_ID) { + port.isUsing = false; + return [rxpacket, result, error]; + } + + // Set packet timeout + if (txpacket[PKT_INSTRUCTION] === INST_READ) { + const length = txpacket[PKT_PARAMETER0 + 1]; + // For READ instructions, we expect response to include the data + port.setPacketTimeout(length + 10); // Add extra buffer + console.log(`Set READ packet timeout for ${length + 10} bytes`); + } else { + // For other instructions, we expect a status packet + port.setPacketTimeout(10); // HEADER0 HEADER1 ID LENGTH ERROR CHECKSUM + buffer + console.log(`Set standard packet timeout for 10 bytes`); + } + + // RX packet - no retries, just attempt once + console.log(`Receiving packet`); + + // Clear port before receiving to ensure clean state + await port.clearPort(); + + const [rxpacketResult, resultRx] = await this.rxPacket(port); + rxpacket = rxpacketResult; + + // Check if received packet is valid + if (resultRx !== COMM_SUCCESS) { + console.log(`Rx failed with result: ${resultRx}`); + port.isUsing = false; + return [rxpacket, resultRx, error]; + } + + // Verify packet structure + if (rxpacket.length < 6) { + console.log(`Received packet too short (${rxpacket.length} bytes)`); + port.isUsing = false; + return [rxpacket, COMM_RX_CORRUPT, error]; + } + + // Verify packet ID matches the sent ID + if (rxpacket[PKT_ID] !== txpacket[PKT_ID]) { + console.log(`Received packet ID (${rxpacket[PKT_ID]}) doesn't match sent ID (${txpacket[PKT_ID]})`); + port.isUsing = false; + return [rxpacket, COMM_RX_CORRUPT, error]; + } + + // Packet looks valid + error = rxpacket[PKT_ERROR]; + port.isUsing = false; // Release port on success + return [rxpacket, resultRx, error]; + + } catch (err) { + console.error("Exception in txRxPacket:", err); + port.isUsing = false; // Release port on exception + return [rxpacket, COMM_RX_FAIL, error]; + } + } + + async ping(port, scsId) { + let modelNumber = 0; + let error = 0; + + try { + if (scsId >= BROADCAST_ID) { + console.log(`Cannot ping broadcast ID ${scsId}`); + return [modelNumber, COMM_NOT_AVAILABLE, error]; + } + + const txpacket = new Array(6).fill(0); + txpacket[PKT_ID] = scsId; + txpacket[PKT_LENGTH] = 2; + txpacket[PKT_INSTRUCTION] = INST_PING; + + console.log(`Pinging servo ID ${scsId}...`); + + // ๅ‘้€pingๆŒ‡ไปคๅนถ่Žทๅ–ๅ“ๅบ” + const [rxpacket, result, err] = await this.txRxPacket(port, txpacket); + error = err; + + // ไธŽPython SDKไฟๆŒไธ€่‡ด๏ผšๅฆ‚ๆžœpingๆˆๅŠŸ๏ผŒๅฐ่ฏ•่ฏปๅ–ๅœฐๅ€3็š„ๅž‹ๅทไฟกๆฏ + if (result === COMM_SUCCESS) { + console.log(`Ping to servo ID ${scsId} succeeded, reading model number from address 3`); + // ่ฏปๅ–ๅœฐๅ€3็š„ๅž‹ๅทไฟกๆฏ๏ผˆ2ๅญ—่Š‚๏ผ‰ + const [data, dataResult, dataError] = await this.readTxRx(port, scsId, 3, 2); + + if (dataResult === COMM_SUCCESS && data && data.length >= 2) { + modelNumber = SCS_MAKEWORD(data[0], data[1]); + console.log(`Model number read: ${modelNumber}`); + } else { + console.log(`Could not read model number: ${this.getTxRxResult(dataResult)}`); + } + } else { + console.log(`Ping failed with result: ${result}, error: ${error}`); + } + + return [modelNumber, result, error]; + } catch (error) { + console.error(`Exception in ping():`, error); + return [0, COMM_RX_FAIL, 0]; + } + } + + // Read methods + async readTxRx(port, scsId, address, length) { + if (scsId >= BROADCAST_ID) { + console.log('Cannot read from broadcast ID'); + return [[], COMM_NOT_AVAILABLE, 0]; + } + + // Create read packet + const txpacket = new Array(8).fill(0); + txpacket[PKT_ID] = scsId; + txpacket[PKT_LENGTH] = 4; + txpacket[PKT_INSTRUCTION] = INST_READ; + txpacket[PKT_PARAMETER0] = address; + txpacket[PKT_PARAMETER0 + 1] = length; + + console.log(`Reading ${length} bytes from address ${address} for servo ID ${scsId}`); + + // Send packet and get response + const [rxpacket, result, error] = await this.txRxPacket(port, txpacket); + + // Process the result + if (result !== COMM_SUCCESS) { + console.log(`Read failed with result: ${result}, error: ${error}`); + return [[], result, error]; + } + + if (!rxpacket || rxpacket.length < PKT_PARAMETER0 + length) { + console.log(`Invalid response packet: expected at least ${PKT_PARAMETER0 + length} bytes, got ${rxpacket ? rxpacket.length : 0}`); + return [[], COMM_RX_CORRUPT, error]; + } + + // Extract data from response + const data = []; + console.log(`Response packet length: ${rxpacket.length}, extracting ${length} bytes from offset ${PKT_PARAMETER0}`); + console.log(`Response data bytes: ${rxpacket.slice(PKT_PARAMETER0, PKT_PARAMETER0 + length).map(b => '0x' + b.toString(16).padStart(2,'0')).join(' ')}`); + + for (let i = 0; i < length; i++) { + data.push(rxpacket[PKT_PARAMETER0 + i]); + } + + console.log(`Successfully read ${length} bytes: ${data.map(b => '0x' + b.toString(16).padStart(2,'0')).join(' ')}`); + return [data, result, error]; + } + + async read1ByteTxRx(port, scsId, address) { + const [data, result, error] = await this.readTxRx(port, scsId, address, 1); + const value = (data.length > 0) ? data[0] : 0; + return [value, result, error]; + } + + async read2ByteTxRx(port, scsId, address) { + const [data, result, error] = await this.readTxRx(port, scsId, address, 2); + + let value = 0; + if (data.length >= 2) { + value = SCS_MAKEWORD(data[0], data[1]); + } + + return [value, result, error]; + } + + async read4ByteTxRx(port, scsId, address) { + const [data, result, error] = await this.readTxRx(port, scsId, address, 4); + + let value = 0; + if (data.length >= 4) { + const loword = SCS_MAKEWORD(data[0], data[1]); + const hiword = SCS_MAKEWORD(data[2], data[3]); + value = SCS_MAKEDWORD(loword, hiword); + + console.log(`read4ByteTxRx: data=${data.map(b => '0x' + b.toString(16).padStart(2,'0')).join(' ')}`); + console.log(` loword=${loword} (0x${loword.toString(16)}), hiword=${hiword} (0x${hiword.toString(16)})`); + console.log(` value=${value} (0x${value.toString(16)})`); + } + + return [value, result, error]; + } + + // Write methods + async writeTxRx(port, scsId, address, length, data) { + if (scsId >= BROADCAST_ID) { + return [COMM_NOT_AVAILABLE, 0]; + } + + // Create write packet + const txpacket = new Array(length + 7).fill(0); + txpacket[PKT_ID] = scsId; + txpacket[PKT_LENGTH] = length + 3; + txpacket[PKT_INSTRUCTION] = INST_WRITE; + txpacket[PKT_PARAMETER0] = address; + + // Add data + for (let i = 0; i < length; i++) { + txpacket[PKT_PARAMETER0 + 1 + i] = data[i] & 0xFF; + } + + // Send packet and get response + const [rxpacket, result, error] = await this.txRxPacket(port, txpacket); + + return [result, error]; + } + + async write1ByteTxRx(port, scsId, address, data) { + const dataArray = [data & 0xFF]; + return await this.writeTxRx(port, scsId, address, 1, dataArray); + } + + async write2ByteTxRx(port, scsId, address, data) { + const dataArray = [ + SCS_LOBYTE(data), + SCS_HIBYTE(data) + ]; + return await this.writeTxRx(port, scsId, address, 2, dataArray); + } + + async write4ByteTxRx(port, scsId, address, data) { + const dataArray = [ + SCS_LOBYTE(SCS_LOWORD(data)), + SCS_HIBYTE(SCS_LOWORD(data)), + SCS_LOBYTE(SCS_HIWORD(data)), + SCS_HIBYTE(SCS_HIWORD(data)) + ]; + return await this.writeTxRx(port, scsId, address, 4, dataArray); + } + + // Add syncReadTx for GroupSyncRead functionality + async syncReadTx(port, startAddress, dataLength, param, paramLength) { + // Create packet: HEADER0 HEADER1 ID LEN INST START_ADDR DATA_LEN PARAM... CHKSUM + const txpacket = new Array(paramLength + 8).fill(0); + + txpacket[PKT_ID] = BROADCAST_ID; + txpacket[PKT_LENGTH] = paramLength + 4; // 4: INST START_ADDR DATA_LEN CHKSUM + txpacket[PKT_INSTRUCTION] = INST_SYNC_READ; + txpacket[PKT_PARAMETER0] = startAddress; + txpacket[PKT_PARAMETER0 + 1] = dataLength; + + // Add parameters + for (let i = 0; i < paramLength; i++) { + txpacket[PKT_PARAMETER0 + 2 + i] = param[i]; + } + + // Calculate checksum + const totalLen = txpacket[PKT_LENGTH] + 4; // 4: HEADER0 HEADER1 ID LENGTH + + // Add headers + txpacket[PKT_HEADER0] = 0xFF; + txpacket[PKT_HEADER1] = 0xFF; + + // Calculate checksum + let checksum = 0; + for (let i = 2; i < totalLen - 1; i++) { + checksum += txpacket[i] & 0xFF; + } + txpacket[totalLen - 1] = (~checksum) & 0xFF; + + console.log(`SyncReadTx: ${txpacket.map(b => '0x' + b.toString(16).padStart(2,'0')).join(' ')}`); + + // Send packet + await port.clearPort(); + const bytesWritten = await port.writePort(txpacket); + if (bytesWritten !== totalLen) { + return COMM_TX_FAIL; + } + + // Set timeout based on expected response size + port.setPacketTimeout((6 + dataLength) * paramLength); + + return COMM_SUCCESS; + } + + // Add syncWriteTxOnly for GroupSyncWrite functionality + async syncWriteTxOnly(port, startAddress, dataLength, param, paramLength) { + // Create packet: HEADER0 HEADER1 ID LEN INST START_ADDR DATA_LEN PARAM... CHKSUM + const txpacket = new Array(paramLength + 8).fill(0); + + txpacket[PKT_ID] = BROADCAST_ID; + txpacket[PKT_LENGTH] = paramLength + 4; // 4: INST START_ADDR DATA_LEN CHKSUM + txpacket[PKT_INSTRUCTION] = INST_SYNC_WRITE; + txpacket[PKT_PARAMETER0] = startAddress; + txpacket[PKT_PARAMETER0 + 1] = dataLength; + + // Add parameters + for (let i = 0; i < paramLength; i++) { + txpacket[PKT_PARAMETER0 + 2 + i] = param[i]; + } + + // Calculate checksum + const totalLen = txpacket[PKT_LENGTH] + 4; // 4: HEADER0 HEADER1 ID LENGTH + + // Add headers + txpacket[PKT_HEADER0] = 0xFF; + txpacket[PKT_HEADER1] = 0xFF; + + // Calculate checksum + let checksum = 0; + for (let i = 2; i < totalLen - 1; i++) { + checksum += txpacket[i] & 0xFF; + } + txpacket[totalLen - 1] = (~checksum) & 0xFF; + + console.log(`SyncWriteTxOnly: ${txpacket.map(b => '0x' + b.toString(16).padStart(2,'0')).join(' ')}`); + + // Send packet - for sync write, we don't need a response + await port.clearPort(); + const bytesWritten = await port.writePort(txpacket); + if (bytesWritten !== totalLen) { + return COMM_TX_FAIL; + } + + return COMM_SUCCESS; + } + + // ่พ…ๅŠฉๆ–นๆณ•๏ผšๆ ผๅผๅŒ–ๆ•ฐๆฎๅŒ…็ป“ๆž„ไปฅๆ–นไพฟ่ฐƒ่ฏ• + formatPacketStructure(packet) { + if (!packet || packet.length < 4) { + return "Invalid packet (too short)"; + } + + try { + let result = ""; + result += `HEADER: ${packet[0].toString(16).padStart(2,'0')} ${packet[1].toString(16).padStart(2,'0')} | `; + result += `ID: ${packet[2]} | `; + result += `LENGTH: ${packet[3]} | `; + + if (packet.length >= 5) { + result += `ERROR/INST: ${packet[4].toString(16).padStart(2,'0')} | `; + } + + if (packet.length >= 6) { + result += "PARAMS: "; + for (let i = 5; i < packet.length - 1; i++) { + result += `${packet[i].toString(16).padStart(2,'0')} `; + } + result += `| CHECKSUM: ${packet[packet.length-1].toString(16).padStart(2,'0')}`; + } + + return result; + } catch (e) { + return "Error formatting packet: " + e.message; + } + } + + /** + * ไปŽๅ“ๅบ”ๅŒ…ไธญ่งฃๆž่ˆตๆœบๅž‹ๅท + * @param {Array} rxpacket - ๅ“ๅบ”ๆ•ฐๆฎๅŒ… + * @returns {number} ่ˆตๆœบๅž‹ๅท + */ + parseModelNumber(rxpacket) { + if (!rxpacket || rxpacket.length < 7) { + return 0; + } + + // ๆฃ€ๆŸฅๆ˜ฏๅฆๆœ‰ๅ‚ๆ•ฐๅญ—ๆฎต + if (rxpacket.length <= PKT_PARAMETER0 + 1) { + return 0; + } + + const param1 = rxpacket[PKT_PARAMETER0]; + const param2 = rxpacket[PKT_PARAMETER0 + 1]; + + if (SCS_END === 0) { + // STS/SMS ๅ่ฎฎ็š„ๅญ—่Š‚้กบๅบ + return SCS_MAKEWORD(param1, param2); + } else { + // SCS ๅ่ฎฎ็š„ๅญ—่Š‚้กบๅบ + return SCS_MAKEWORD(param2, param1); + } + } + + /** + * Verify packet header + * @param {Array} packet - The packet to verify + * @returns {Number} COMM_SUCCESS if packet is valid, error code otherwise + */ + getPacketHeader(packet) { + if (!packet || packet.length < 4) { + return COMM_RX_CORRUPT; + } + + // Check header + if (packet[PKT_HEADER0] !== 0xFF || packet[PKT_HEADER1] !== 0xFF) { + return COMM_RX_CORRUPT; + } + + // Check ID validity + if (packet[PKT_ID] > 0xFD) { + return COMM_RX_CORRUPT; + } + + // Check length + if (packet.length != (packet[PKT_LENGTH] + 4)) { + return COMM_RX_CORRUPT; + } + + // Calculate checksum + let checksum = 0; + for (let i = 2; i < packet.length - 1; i++) { + checksum += packet[i] & 0xFF; + } + checksum = (~checksum) & 0xFF; + + // Verify checksum + if (packet[packet.length - 1] !== checksum) { + return COMM_RX_CORRUPT; + } + + return COMM_SUCCESS; + } +} + +/** + * GroupSyncRead class + * - This class is used to read multiple servos with the same control table address at once + */ +export class GroupSyncRead { + constructor(port, ph, startAddress, dataLength) { + this.port = port; + this.ph = ph; + this.startAddress = startAddress; + this.dataLength = dataLength; + + this.isAvailableServiceID = new Set(); + this.dataDict = new Map(); + this.param = []; + this.clearParam(); + } + + makeParam() { + this.param = []; + for (const id of this.isAvailableServiceID) { + this.param.push(id); + } + return this.param.length; + } + + addParam(scsId) { + if (this.isAvailableServiceID.has(scsId)) { + return false; + } + + this.isAvailableServiceID.add(scsId); + this.dataDict.set(scsId, new Array(this.dataLength).fill(0)); + return true; + } + + removeParam(scsId) { + if (!this.isAvailableServiceID.has(scsId)) { + return false; + } + + this.isAvailableServiceID.delete(scsId); + this.dataDict.delete(scsId); + return true; + } + + clearParam() { + this.isAvailableServiceID.clear(); + this.dataDict.clear(); + return true; + } + + async txPacket() { + if (this.isAvailableServiceID.size === 0) { + return COMM_NOT_AVAILABLE; + } + + const paramLength = this.makeParam(); + return await this.ph.syncReadTx(this.port, this.startAddress, this.dataLength, this.param, paramLength); + } + + async rxPacket() { + let result = COMM_RX_FAIL; + + if (this.isAvailableServiceID.size === 0) { + return COMM_NOT_AVAILABLE; + } + + // Set all servos' data as invalid + for (const id of this.isAvailableServiceID) { + this.dataDict.set(id, new Array(this.dataLength).fill(0)); + } + + const [rxpacket, rxResult] = await this.ph.rxPacket(this.port); + if (rxResult !== COMM_SUCCESS || !rxpacket || rxpacket.length === 0) { + return rxResult; + } + + // More tolerant of packets with unexpected values in the PKT_ERROR field + // Don't require INST_STATUS to be exactly 0x55 + console.log(`GroupSyncRead rxPacket: ID=${rxpacket[PKT_ID]}, ERROR/INST field=0x${rxpacket[PKT_ERROR].toString(16)}`); + + // Check if the packet matches any of the available IDs + if (!this.isAvailableServiceID.has(rxpacket[PKT_ID])) { + console.log(`Received packet with ID ${rxpacket[PKT_ID]} which is not in the available IDs list`); + return COMM_RX_CORRUPT; + } + + // Extract data for the matching ID + const scsId = rxpacket[PKT_ID]; + const data = new Array(this.dataLength).fill(0); + + // Extract the parameter data, which should start at PKT_PARAMETER0 + if (rxpacket.length < PKT_PARAMETER0 + this.dataLength) { + console.log(`Packet too short: expected ${PKT_PARAMETER0 + this.dataLength} bytes, got ${rxpacket.length}`); + return COMM_RX_CORRUPT; + } + + for (let i = 0; i < this.dataLength; i++) { + data[i] = rxpacket[PKT_PARAMETER0 + i]; + } + + // Update the data dict + this.dataDict.set(scsId, data); + console.log(`Updated data for servo ID ${scsId}: ${data.map(b => '0x' + b.toString(16).padStart(2,'0')).join(' ')}`); + + // Continue receiving until timeout or all data is received + if (this.isAvailableServiceID.size > 1) { + result = await this.rxPacket(); + } else { + result = COMM_SUCCESS; + } + + return result; + } + + async txRxPacket() { + try { + // First check if port is being used + if (this.port.isUsing) { + console.log("Port is busy, cannot start sync read operation"); + return COMM_PORT_BUSY; + } + + // Start the transmission + console.log("Starting sync read TX/RX operation..."); + let result = await this.txPacket(); + if (result !== COMM_SUCCESS) { + console.log(`Sync read TX failed with result: ${result}`); + return result; + } + + // Get a single response with a standard timeout + console.log(`Attempting to receive a response...`); + + // Receive a single response + result = await this.rxPacket(); + + // Release port + this.port.isUsing = false; + + return result; + } catch (error) { + console.error("Exception in GroupSyncRead txRxPacket:", error); + // Make sure port is released + this.port.isUsing = false; + return COMM_RX_FAIL; + } + } + + isAvailable(scsId, address, dataLength) { + if (!this.isAvailableServiceID.has(scsId)) { + return false; + } + + const startAddr = this.startAddress; + const endAddr = startAddr + this.dataLength - 1; + + const reqStartAddr = address; + const reqEndAddr = reqStartAddr + dataLength - 1; + + if (reqStartAddr < startAddr || reqEndAddr > endAddr) { + return false; + } + + const data = this.dataDict.get(scsId); + if (!data || data.length === 0) { + return false; + } + + return true; + } + + getData(scsId, address, dataLength) { + if (!this.isAvailable(scsId, address, dataLength)) { + return 0; + } + + const startAddr = this.startAddress; + const data = this.dataDict.get(scsId); + + // Calculate data offset + const dataOffset = address - startAddr; + + // Combine bytes according to dataLength + switch (dataLength) { + case 1: + return data[dataOffset]; + case 2: + return SCS_MAKEWORD(data[dataOffset], data[dataOffset + 1]); + case 4: + return SCS_MAKEDWORD( + SCS_MAKEWORD(data[dataOffset], data[dataOffset + 1]), + SCS_MAKEWORD(data[dataOffset + 2], data[dataOffset + 3]) + ); + default: + return 0; + } + } +} + +/** + * GroupSyncWrite class + * - This class is used to write multiple servos with the same control table address at once + */ +export class GroupSyncWrite { + constructor(port, ph, startAddress, dataLength) { + this.port = port; + this.ph = ph; + this.startAddress = startAddress; + this.dataLength = dataLength; + + this.isAvailableServiceID = new Set(); + this.dataDict = new Map(); + this.param = []; + this.clearParam(); + } + + makeParam() { + this.param = []; + for (const id of this.isAvailableServiceID) { + // Add ID to parameter + this.param.push(id); + + // Add data to parameter + const data = this.dataDict.get(id); + for (let i = 0; i < this.dataLength; i++) { + this.param.push(data[i]); + } + } + return this.param.length; + } + + addParam(scsId, data) { + if (this.isAvailableServiceID.has(scsId)) { + return false; + } + + if (data.length !== this.dataLength) { + console.error(`Data length (${data.length}) doesn't match required length (${this.dataLength})`); + return false; + } + + this.isAvailableServiceID.add(scsId); + this.dataDict.set(scsId, data); + return true; + } + + removeParam(scsId) { + if (!this.isAvailableServiceID.has(scsId)) { + return false; + } + + this.isAvailableServiceID.delete(scsId); + this.dataDict.delete(scsId); + return true; + } + + changeParam(scsId, data) { + if (!this.isAvailableServiceID.has(scsId)) { + return false; + } + + if (data.length !== this.dataLength) { + console.error(`Data length (${data.length}) doesn't match required length (${this.dataLength})`); + return false; + } + + this.dataDict.set(scsId, data); + return true; + } + + clearParam() { + this.isAvailableServiceID.clear(); + this.dataDict.clear(); + return true; + } + + async txPacket() { + if (this.isAvailableServiceID.size === 0) { + return COMM_NOT_AVAILABLE; + } + + const paramLength = this.makeParam(); + return await this.ph.syncWriteTxOnly(this.port, this.startAddress, this.dataLength, this.param, paramLength); + } +} diff --git a/src/lib/feetech.js/package.json b/src/lib/feetech.js/package.json new file mode 100644 index 0000000000000000000000000000000000000000..831847ddf1eb4bfe96cf21d47f306b9ea25c457a --- /dev/null +++ b/src/lib/feetech.js/package.json @@ -0,0 +1,38 @@ +{ + "name": "feetech.js", + "version": "0.0.8", + "description": "javascript sdk for feetech servos", + "main": "index.mjs", + "files": [ + "*.mjs", + "*.ts" + ], + "type": "module", + "engines": { + "node": ">=12.17.0" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/timqian/bambot/tree/main/feetech.js" + }, + "keywords": [ + "feetech", + "sdk", + "js", + "javascript", + "sts3215", + "3215", + "scs", + "scs3215", + "st3215" + ], + "author": "timqian", + "license": "MIT", + "bugs": { + "url": "https://github.com/timqian/bambot/issues" + }, + "homepage": "https://github.com/timqian/bambot/tree/main/feetech.js" +} diff --git a/src/lib/feetech.js/scsServoSDK.mjs b/src/lib/feetech.js/scsServoSDK.mjs new file mode 100644 index 0000000000000000000000000000000000000000..9eba02d515a22f55098b8337e47744e25c19a90d --- /dev/null +++ b/src/lib/feetech.js/scsServoSDK.mjs @@ -0,0 +1,973 @@ +import { + PortHandler, + PacketHandler, + COMM_SUCCESS, + COMM_RX_TIMEOUT, + COMM_RX_CORRUPT, + COMM_TX_FAIL, + COMM_NOT_AVAILABLE, + SCS_LOBYTE, + SCS_HIBYTE, + SCS_MAKEWORD, + GroupSyncRead, // Import GroupSyncRead + GroupSyncWrite, // Import GroupSyncWrite +} from "./lowLevelSDK.mjs"; + +// Import address constants from the correct file +import { + ADDR_SCS_PRESENT_POSITION, + ADDR_SCS_GOAL_POSITION, + ADDR_SCS_TORQUE_ENABLE, + ADDR_SCS_GOAL_ACC, + ADDR_SCS_GOAL_SPEED, +} from "./scsservo_constants.mjs"; + +// Define constants not present in scsservo_constants.mjs +const ADDR_SCS_MODE = 33; +const ADDR_SCS_LOCK = 55; +const ADDR_SCS_ID = 5; // Address for Servo ID +const ADDR_SCS_BAUD_RATE = 6; // Address for Baud Rate + +// Module-level variables for handlers +let portHandler = null; +let packetHandler = null; + +/** + * Connects to the serial port and initializes handlers. + * @param {object} [options] - Connection options. + * @param {number} [options.baudRate=1000000] - The baud rate for the serial connection. + * @param {number} [options.protocolEnd=0] - The protocol end setting (0 for STS/SMS, 1 for SCS). + * @returns {Promise} Resolves with true on successful connection. + * @throws {Error} If connection fails or port cannot be opened/selected. + */ +export async function connect(options = {}) { + if (portHandler && portHandler.isOpen) { + console.log("Already connected."); + return true; + } + + const { baudRate = 1000000, protocolEnd = 0 } = options; + + try { + portHandler = new PortHandler(); + const portRequested = await portHandler.requestPort(); + if (!portRequested) { + portHandler = null; + throw new Error("Failed to select a serial port."); + } + + portHandler.setBaudRate(baudRate); + const portOpened = await portHandler.openPort(); + if (!portOpened) { + await portHandler.closePort().catch(console.error); // Attempt cleanup + portHandler = null; + throw new Error(`Failed to open port at baudrate ${baudRate}.`); + } + + packetHandler = new PacketHandler(protocolEnd); + console.log( + `Connected to serial port at ${baudRate} baud, protocol end: ${protocolEnd}.` + ); + return true; + } catch (err) { + console.error("Error during connection:", err); + if (portHandler) { + try { + await portHandler.closePort(); + } catch (closeErr) { + console.error("Error closing port after connection failure:", closeErr); + } + } + portHandler = null; + packetHandler = null; + // Re-throw the original or a new error + throw new Error(`Connection failed: ${err.message}`); + } +} + +/** + * Disconnects from the serial port. + * @returns {Promise} Resolves with true on successful disconnection. + * @throws {Error} If disconnection fails. + */ +export async function disconnect() { + if (!portHandler || !portHandler.isOpen) { + console.log("Already disconnected."); + return true; + } + + try { + await portHandler.closePort(); + portHandler = null; + packetHandler = null; + console.log("Disconnected from serial port."); + return true; + } catch (err) { + console.error("Error during disconnection:", err); + // Attempt to nullify handlers even if close fails + portHandler = null; + packetHandler = null; + throw new Error(`Disconnection failed: ${err.message}`); + } +} + +/** + * Checks if the SDK is connected. Throws an error if not. + * @throws {Error} If not connected. + */ +function checkConnection() { + if (!portHandler || !packetHandler) { + throw new Error("Not connected. Call connect() first."); + } +} + +/** + * Reads the current position of a servo. + * @param {number} servoId - The ID of the servo (1-252). + * @returns {Promise} Resolves with the position (0-4095). + * @throws {Error} If not connected, read fails, or an exception occurs. + */ +export async function readPosition(servoId) { + checkConnection(); + try { + const [position, result, error] = await packetHandler.read2ByteTxRx( + portHandler, + servoId, + ADDR_SCS_PRESENT_POSITION + ); + + if (result !== COMM_SUCCESS) { + throw new Error( + `Error reading position from servo ${servoId}: ${packetHandler.getTxRxResult( + result + )}, Error code: ${error}` + ); + } + return position & 0xffff; // Ensure it's within 16 bits + } catch (err) { + console.error(`Exception reading position from servo ${servoId}:`, err); + throw new Error( + `Exception reading position from servo ${servoId}: ${err.message}` + ); + } +} + +/** + * Reads the current baud rate index of a servo. + * @param {number} servoId - The ID of the servo (1-252). + * @returns {Promise} Resolves with the baud rate index (0-7). + * @throws {Error} If not connected, read fails, or an exception occurs. + */ +export async function readBaudRate(servoId) { + checkConnection(); + try { + const [baudIndex, result, error] = await packetHandler.read1ByteTxRx( + portHandler, + servoId, + ADDR_SCS_BAUD_RATE + ); + + if (result !== COMM_SUCCESS) { + throw new Error( + `Error reading baud rate from servo ${servoId}: ${packetHandler.getTxRxResult( + result + )}, Error code: ${error}` + ); + } + return baudIndex; + } catch (err) { + console.error(`Exception reading baud rate from servo ${servoId}:`, err); + throw new Error( + `Exception reading baud rate from servo ${servoId}: ${err.message}` + ); + } +} + +/** + * Reads the current operating mode of a servo. + * @param {number} servoId - The ID of the servo (1-252). + * @returns {Promise} Resolves with the mode (0 for position, 1 for wheel). + * @throws {Error} If not connected, read fails, or an exception occurs. + */ +export async function readMode(servoId) { + checkConnection(); + try { + const [modeValue, result, error] = await packetHandler.read1ByteTxRx( + portHandler, + servoId, + ADDR_SCS_MODE + ); + + if (result !== COMM_SUCCESS) { + throw new Error( + `Error reading mode from servo ${servoId}: ${packetHandler.getTxRxResult( + result + )}, Error code: ${error}` + ); + } + return modeValue; + } catch (err) { + console.error(`Exception reading mode from servo ${servoId}:`, err); + throw new Error( + `Exception reading mode from servo ${servoId}: ${err.message}` + ); + } +} + +/** + * Writes a target position to a servo. + * @param {number} servoId - The ID of the servo (1-252). + * @param {number} position - The target position value (0-4095). + * @returns {Promise<"success">} Resolves with "success". + * @throws {Error} If not connected, position is out of range, write fails, or an exception occurs. + */ +export async function writePosition(servoId, position) { + checkConnection(); + try { + // Validate position range + if (position < 0 || position > 4095) { + throw new Error( + `Invalid position value ${position} for servo ${servoId}. Must be between 0 and 4095.` + ); + } + const targetPosition = Math.round(position); // Ensure integer value + + const [result, error] = await packetHandler.write2ByteTxRx( + portHandler, + servoId, + ADDR_SCS_GOAL_POSITION, + targetPosition + ); + + if (result !== COMM_SUCCESS) { + throw new Error( + `Error writing position to servo ${servoId}: ${packetHandler.getTxRxResult( + result + )}, Error code: ${error}` + ); + } + return "success"; + } catch (err) { + console.error(`Exception writing position to servo ${servoId}:`, err); + // Re-throw the original error or a new one wrapping it + throw new Error( + `Failed to write position to servo ${servoId}: ${err.message}` + ); + } +} + +/** + * Enables or disables the torque of a servo. + * @param {number} servoId - The ID of the servo (1-252). + * @param {boolean} enable - True to enable torque, false to disable. + * @returns {Promise<"success">} Resolves with "success". + * @throws {Error} If not connected, write fails, or an exception occurs. + */ +export async function writeTorqueEnable(servoId, enable) { + checkConnection(); + try { + const enableValue = enable ? 1 : 0; + const [result, error] = await packetHandler.write1ByteTxRx( + portHandler, + servoId, + ADDR_SCS_TORQUE_ENABLE, + enableValue + ); + + if (result !== COMM_SUCCESS) { + throw new Error( + `Error setting torque for servo ${servoId}: ${packetHandler.getTxRxResult( + result + )}, Error code: ${error}` + ); + } + return "success"; + } catch (err) { + console.error(`Exception setting torque for servo ${servoId}:`, err); + throw new Error( + `Exception setting torque for servo ${servoId}: ${err.message}` + ); + } +} + +/** + * Sets the acceleration profile for a servo's movement. + * @param {number} servoId - The ID of the servo (1-252). + * @param {number} acceleration - The acceleration value (0-254). + * @returns {Promise<"success">} Resolves with "success". + * @throws {Error} If not connected, write fails, or an exception occurs. + */ +export async function writeAcceleration(servoId, acceleration) { + checkConnection(); + try { + const clampedAcceleration = Math.max( + 0, + Math.min(254, Math.round(acceleration)) + ); + const [result, error] = await packetHandler.write1ByteTxRx( + portHandler, + servoId, + ADDR_SCS_GOAL_ACC, + clampedAcceleration + ); + + if (result !== COMM_SUCCESS) { + throw new Error( + `Error writing acceleration to servo ${servoId}: ${packetHandler.getTxRxResult( + result + )}, Error code: ${error}` + ); + } + return "success"; + } catch (err) { + console.error(`Exception writing acceleration to servo ${servoId}:`, err); + throw new Error( + `Exception writing acceleration to servo ${servoId}: ${err.message}` + ); + } +} + +/** + * Helper to attempt locking a servo, logging errors without throwing. + * @param {number} servoId + */ +async function tryLockServo(servoId) { + try { + await packetHandler.write1ByteTxRx(portHandler, servoId, ADDR_SCS_LOCK, 1); + } catch (lockErr) { + console.error(`Failed to re-lock servo ${servoId}:`, lockErr); + } +} + +/** + * Sets a servo to wheel mode (continuous rotation). + * Requires unlocking, setting mode, and locking the configuration. + * @param {number} servoId - The ID of the servo (1-252). + * @returns {Promise<"success">} Resolves with "success". + * @throws {Error} If not connected, any step fails, or an exception occurs. + */ +export async function setWheelMode(servoId) { + checkConnection(); + let unlocked = false; + try { + console.log(`Setting servo ${servoId} to wheel mode...`); + + // 1. Unlock servo configuration + const [resUnlock, errUnlock] = await packetHandler.write1ByteTxRx( + portHandler, + servoId, + ADDR_SCS_LOCK, + 0 + ); + if (resUnlock !== COMM_SUCCESS) { + throw new Error( + `Failed to unlock servo ${servoId}: ${packetHandler.getTxRxResult( + resUnlock + )}, Error: ${errUnlock}` + ); + } + unlocked = true; + + // 2. Set mode to 1 (Wheel/Speed mode) + const [resMode, errMode] = await packetHandler.write1ByteTxRx( + portHandler, + servoId, + ADDR_SCS_MODE, + 1 + ); + if (resMode !== COMM_SUCCESS) { + throw new Error( + `Failed to set wheel mode for servo ${servoId}: ${packetHandler.getTxRxResult( + resMode + )}, Error: ${errMode}` + ); + } + + // 3. Lock servo configuration + const [resLock, errLock] = await packetHandler.write1ByteTxRx( + portHandler, + servoId, + ADDR_SCS_LOCK, + 1 + ); + if (resLock !== COMM_SUCCESS) { + // Mode was set, but lock failed. Still an error state. + throw new Error( + `Failed to lock servo ${servoId} after setting mode: ${packetHandler.getTxRxResult( + resLock + )}, Error: ${errLock}` + ); + } + unlocked = false; // Successfully locked + + console.log(`Successfully set servo ${servoId} to wheel mode.`); + return "success"; + } catch (err) { + console.error(`Exception setting wheel mode for servo ${servoId}:`, err); + if (unlocked) { + // Attempt to re-lock if an error occurred after unlocking + await tryLockServo(servoId); + } + // Re-throw the original error or a new one wrapping it + throw new Error( + `Failed to set wheel mode for servo ${servoId}: ${err.message}` + ); + } +} + +/** + * Sets a servo back to position control mode from wheel mode. + * Requires unlocking, setting mode, and locking the configuration. + * @param {number} servoId - The ID of the servo (1-252). + * @returns {Promise<"success">} Resolves with "success". + * @throws {Error} If not connected, any step fails, or an exception occurs. + */ +export async function setPositionMode(servoId) { + checkConnection(); + let unlocked = false; + try { + console.log(`Setting servo ${servoId} back to position mode...`); + + // 1. Unlock servo configuration + const [resUnlock, errUnlock] = await packetHandler.write1ByteTxRx( + portHandler, + servoId, + ADDR_SCS_LOCK, + 0 + ); + if (resUnlock !== COMM_SUCCESS) { + throw new Error( + `Failed to unlock servo ${servoId}: ${packetHandler.getTxRxResult( + resUnlock + )}, Error: ${errUnlock}` + ); + } + unlocked = true; + + // 2. Set mode to 0 (Position/Servo mode) + const [resMode, errMode] = await packetHandler.write1ByteTxRx( + portHandler, + servoId, + ADDR_SCS_MODE, + 0 // 0 for position mode + ); + if (resMode !== COMM_SUCCESS) { + throw new Error( + `Failed to set position mode for servo ${servoId}: ${packetHandler.getTxRxResult( + resMode + )}, Error: ${errMode}` + ); + } + + // 3. Lock servo configuration + const [resLock, errLock] = await packetHandler.write1ByteTxRx( + portHandler, + servoId, + ADDR_SCS_LOCK, + 1 + ); + if (resLock !== COMM_SUCCESS) { + throw new Error( + `Failed to lock servo ${servoId} after setting mode: ${packetHandler.getTxRxResult( + resLock + )}, Error: ${errLock}` + ); + } + unlocked = false; // Successfully locked + + console.log(`Successfully set servo ${servoId} back to position mode.`); + return "success"; + } catch (err) { + console.error(`Exception setting position mode for servo ${servoId}:`, err); + if (unlocked) { + // Attempt to re-lock if an error occurred after unlocking + await tryLockServo(servoId); + } + throw new Error( + `Failed to set position mode for servo ${servoId}: ${err.message}` + ); + } +} + +/** + * Writes a target speed for a servo in wheel mode. + * @param {number} servoId - The ID of the servo + * @param {number} speed - The target speed value (-10000 to 10000). Negative values indicate reverse direction. 0 stops the wheel. + * @returns {Promise<"success">} Resolves with "success". + * @throws {Error} If not connected, either write fails, or an exception occurs. + */ +export async function writeWheelSpeed(servoId, speed) { + checkConnection(); + try { + // Validate and clamp the speed to the new range + const clampedSpeed = Math.max(-10000, Math.min(10000, Math.round(speed))); + let speedValue = Math.abs(clampedSpeed) & 0x7fff; // Get absolute value, ensure within 15 bits + + // Set the direction bit (MSB of the 16-bit value) if speed is negative + if (clampedSpeed < 0) { + speedValue |= 0x8000; // Set the 16th bit for reverse direction + } + + // Use write2ByteTxRx to write the 16-bit speed value + const [result, error] = await packetHandler.write2ByteTxRx( + portHandler, + servoId, + ADDR_SCS_GOAL_SPEED, // Starting address for the 2-byte speed value + speedValue + ); + + if (result !== COMM_SUCCESS) { + throw new Error( + `Error writing wheel speed to servo ${servoId}: ${packetHandler.getTxRxResult( + result + )}, Error: ${error}` + ); + } + + return "success"; + } catch (err) { + console.error(`Exception writing wheel speed to servo ${servoId}:`, err); + throw new Error( + `Exception writing wheel speed to servo ${servoId}: ${err.message}` + ); + } +} + +/** + * Writes target speeds to multiple servos in wheel mode synchronously. + * @param {Map | object} servoSpeeds - A Map or object where keys are servo IDs (1-252) and values are target speeds (-10000 to 10000). + * @returns {Promise<"success">} Resolves with "success". + * @throws {Error} If not connected, any speed is out of range, transmission fails, or an exception occurs. + */ +export async function syncWriteWheelSpeed(servoSpeeds) { + checkConnection(); + + const groupSyncWrite = new GroupSyncWrite( + portHandler, + packetHandler, + ADDR_SCS_GOAL_SPEED, + 2 // Data length for speed (2 bytes) + ); + let paramAdded = false; + + const entries = + servoSpeeds instanceof Map + ? servoSpeeds.entries() + : Object.entries(servoSpeeds); + + // Second pass: Add valid parameters + for (const [idStr, speed] of entries) { + const servoId = parseInt(idStr, 10); // Already validated + + if (isNaN(servoId) || servoId < 1 || servoId > 252) { + throw new Error(`Invalid servo ID "${idStr}" in syncWriteWheelSpeed.`); + } + if (speed < -10000 || speed > 10000) { + throw new Error( + `Invalid speed value ${speed} for servo ${servoId} in syncWriteWheelSpeed. Must be between -10000 and 10000.` + ); + } + + const clampedSpeed = Math.max(-10000, Math.min(10000, Math.round(speed))); // Ensure integer, already validated range + let speedValue = Math.abs(clampedSpeed) & 0x7fff; // Get absolute value, ensure within 15 bits + + // Set the direction bit (MSB of the 16-bit value) if speed is negative + if (clampedSpeed < 0) { + speedValue |= 0x8000; // Set the 16th bit for reverse direction + } + + const data = [SCS_LOBYTE(speedValue), SCS_HIBYTE(speedValue)]; + + if (groupSyncWrite.addParam(servoId, data)) { + paramAdded = true; + } else { + // This should ideally not happen if IDs are unique, but handle defensively + console.warn( + `Failed to add servo ${servoId} to sync write speed group (possibly duplicate).` + ); + } + } + + if (!paramAdded) { + console.log("Sync Write Speed: No valid servo speeds provided or added."); + return "success"; // Nothing to write is considered success + } + + try { + // Send the Sync Write instruction + const result = await groupSyncWrite.txPacket(); + if (result !== COMM_SUCCESS) { + throw new Error( + `Sync Write Speed txPacket failed: ${packetHandler.getTxRxResult( + result + )}` + ); + } + return "success"; + } catch (err) { + console.error("Exception during syncWriteWheelSpeed:", err); + // Re-throw the original error or a new one wrapping it + throw new Error(`Sync Write Speed failed: ${err.message}`); + } +} + +/** + * Reads the current position of multiple servos synchronously. + * @param {number[]} servoIds - An array of servo IDs (1-252) to read from. + * @returns {Promise>} Resolves with a Map where keys are servo IDs and values are positions (0-4095). + * @throws {Error} If not connected, transmission fails, reception fails, or data for any requested servo is unavailable. + */ +export async function syncReadPositions(servoIds) { + checkConnection(); + if (!Array.isArray(servoIds) || servoIds.length === 0) { + console.log("Sync Read: No servo IDs provided."); + return new Map(); // Return empty map for empty input + } + + const startAddress = ADDR_SCS_PRESENT_POSITION; + const dataLength = 2; + const groupSyncRead = new GroupSyncRead( + portHandler, + packetHandler, + startAddress, + dataLength + ); + const positions = new Map(); + const validIds = []; + + // 1. Add parameters for each valid servo ID + servoIds.forEach((id) => { + if (id >= 1 && id <= 252) { + if (groupSyncRead.addParam(id)) { + validIds.push(id); + } else { + console.warn( + `Sync Read: Failed to add param for servo ID ${id} (maybe duplicate or invalid).` + ); + } + } else { + console.warn(`Sync Read: Invalid servo ID ${id} skipped.`); + } + }); + + if (validIds.length === 0) { + console.log("Sync Read: No valid servo IDs to read."); + return new Map(); // Return empty map if no valid IDs + } + + try { + // 2. Send the Sync Read instruction packet + let txResult = await groupSyncRead.txPacket(); + if (txResult !== COMM_SUCCESS) { + throw new Error( + `Sync Read txPacket failed: ${packetHandler.getTxRxResult(txResult)}` + ); + } + + // 3. Receive the response packets + let rxResult = await groupSyncRead.rxPacket(); + // Even if rxPacket reports an overall issue (like timeout), we still check individual servos. + // A specific error will be thrown later if any servo data is missing. + if (rxResult !== COMM_SUCCESS) { + console.warn( + `Sync Read rxPacket overall result: ${packetHandler.getTxRxResult( + rxResult + )}. Checking individual servos.` + ); + } + + // 4. Check data availability and retrieve data for each servo + const failedIds = []; + validIds.forEach((id) => { + const isAvailable = groupSyncRead.isAvailable( + id, + startAddress, + dataLength + ); + if (isAvailable) { + const position = groupSyncRead.getData(id, startAddress, dataLength); + positions.set(id, position & 0xffff); + } else { + failedIds.push(id); + } + }); + + // 5. Check if all requested servos responded + if (failedIds.length > 0) { + throw new Error( + `Sync Read failed: Data not available for servo IDs: ${failedIds.join( + ", " + )}. Overall RX result: ${packetHandler.getTxRxResult(rxResult)}` + ); + } + + return positions; + } catch (err) { + console.error("Exception or failure during syncReadPositions:", err); + // Re-throw the caught error or a new one wrapping it + throw new Error(`Sync Read failed: ${err.message}`); + } +} + +/** + * Writes target positions to multiple servos synchronously. + * @param {Map | object} servoPositions - A Map or object where keys are servo IDs (1-252) and values are target positions (0-4095). + * @returns {Promise<"success">} Resolves with "success". + * @throws {Error} If not connected, any position is out of range, transmission fails, or an exception occurs. + */ +export async function syncWritePositions(servoPositions) { + checkConnection(); + + const groupSyncWrite = new GroupSyncWrite( + portHandler, + packetHandler, + ADDR_SCS_GOAL_POSITION, + 2 // Data length for position + ); + let paramAdded = false; + + const entries = + servoPositions instanceof Map + ? servoPositions.entries() + : Object.entries(servoPositions); + + // Second pass: Add valid parameters + for (const [idStr, position] of entries) { + const servoId = parseInt(idStr, 10); // Already validated + if (isNaN(servoId) || servoId < 1 || servoId > 252) { + throw new Error(`Invalid servo ID "${idStr}" in syncWritePositions.`); + } + if (position < 0 || position > 4095) { + throw new Error( + `Invalid position value ${position} for servo ${servoId} in syncWritePositions. Must be between 0 and 4095.` + ); + } + const targetPosition = Math.round(position); // Ensure integer, already validated range + const data = [SCS_LOBYTE(targetPosition), SCS_HIBYTE(targetPosition)]; + + if (groupSyncWrite.addParam(servoId, data)) { + paramAdded = true; + } else { + // This should ideally not happen if IDs are unique, but handle defensively + console.warn( + `Failed to add servo ${servoId} to sync write group (possibly duplicate).` + ); + } + } + + if (!paramAdded) { + console.log("Sync Write: No valid servo positions provided or added."); + return "success"; // Nothing to write is considered success + } + + try { + // Send the Sync Write instruction + const result = await groupSyncWrite.txPacket(); + if (result !== COMM_SUCCESS) { + throw new Error( + `Sync Write txPacket failed: ${packetHandler.getTxRxResult(result)}` + ); + } + return "success"; + } catch (err) { + console.error("Exception during syncWritePositions:", err); + // Re-throw the original error or a new one wrapping it + throw new Error(`Sync Write failed: ${err.message}`); + } +} + +/** + * Sets the Baud Rate of a servo. + * NOTE: After changing the baud rate, you might need to disconnect and reconnect + * at the new baud rate to communicate with the servo further. + * @param {number} servoId - The current ID of the servo to configure (1-252). + * @param {number} baudRateIndex - The index representing the new baud rate (0-7). + * @returns {Promise<"success">} Resolves with "success". + * @throws {Error} If not connected, input is invalid, any step fails, or an exception occurs. + */ +export async function setBaudRate(servoId, baudRateIndex) { + checkConnection(); + + // Validate inputs + if (servoId < 1 || servoId > 252) { + throw new Error( + `Invalid servo ID provided: ${servoId}. Must be between 1 and 252.` + ); + } + if (baudRateIndex < 0 || baudRateIndex > 7) { + throw new Error( + `Invalid baudRateIndex: ${baudRateIndex}. Must be between 0 and 7.` + ); + } + + let unlocked = false; + try { + console.log( + `Setting baud rate for servo ${servoId}: Index=${baudRateIndex}` + ); + + // 1. Unlock servo configuration + const [resUnlock, errUnlock] = await packetHandler.write1ByteTxRx( + portHandler, + servoId, + ADDR_SCS_LOCK, + 0 // 0 to unlock + ); + if (resUnlock !== COMM_SUCCESS) { + throw new Error( + `Failed to unlock servo ${servoId}: ${packetHandler.getTxRxResult( + resUnlock + )}, Error: ${errUnlock}` + ); + } + unlocked = true; + + // 2. Write new Baud Rate index + const [resBaud, errBaud] = await packetHandler.write1ByteTxRx( + portHandler, + servoId, + ADDR_SCS_BAUD_RATE, + baudRateIndex + ); + if (resBaud !== COMM_SUCCESS) { + throw new Error( + `Failed to write baud rate index ${baudRateIndex} to servo ${servoId}: ${packetHandler.getTxRxResult( + resBaud + )}, Error: ${errBaud}` + ); + } + + // 3. Lock servo configuration + const [resLock, errLock] = await packetHandler.write1ByteTxRx( + portHandler, + servoId, + ADDR_SCS_LOCK, + 1 + ); + if (resLock !== COMM_SUCCESS) { + throw new Error( + `Failed to lock servo ${servoId} after setting baud rate: ${packetHandler.getTxRxResult( + resLock + )}, Error: ${errLock}.` + ); + } + unlocked = false; // Successfully locked + + console.log( + `Successfully set baud rate for servo ${servoId}. Index: ${baudRateIndex}. Remember to potentially reconnect with the new baud rate.` + ); + return "success"; + } catch (err) { + console.error(`Exception during setBaudRate for servo ID ${servoId}:`, err); + if (unlocked) { + await tryLockServo(servoId); + } + throw new Error( + `Failed to set baud rate for servo ${servoId}: ${err.message}` + ); + } +} + +/** + * Sets the ID of a servo. + * NOTE: Changing the ID requires using the new ID for subsequent commands. + * @param {number} currentServoId - The current ID of the servo to configure (1-252). + * @param {number} newServoId - The new ID to set for the servo (1-252). + * @returns {Promise<"success">} Resolves with "success". + * @throws {Error} If not connected, input is invalid, any step fails, or an exception occurs. + */ +export async function setServoId(currentServoId, newServoId) { + checkConnection(); + + // Validate inputs + if ( + currentServoId < 1 || + currentServoId > 252 || + newServoId < 1 || + newServoId > 252 + ) { + throw new Error( + `Invalid servo ID provided. Current: ${currentServoId}, New: ${newServoId}. Must be between 1 and 252.` + ); + } + + if (currentServoId === newServoId) { + console.log(`Servo ID is already ${newServoId}. No change needed.`); + return "success"; + } + + let unlocked = false; + let idWritten = false; + try { + console.log(`Setting servo ID: From ${currentServoId} to ${newServoId}`); + + // 1. Unlock servo configuration (using current ID) + const [resUnlock, errUnlock] = await packetHandler.write1ByteTxRx( + portHandler, + currentServoId, + ADDR_SCS_LOCK, + 0 // 0 to unlock + ); + if (resUnlock !== COMM_SUCCESS) { + throw new Error( + `Failed to unlock servo ${currentServoId}: ${packetHandler.getTxRxResult( + resUnlock + )}, Error: ${errUnlock}` + ); + } + unlocked = true; + + // 2. Write new Servo ID (using current ID) + const [resId, errId] = await packetHandler.write1ByteTxRx( + portHandler, + currentServoId, + ADDR_SCS_ID, + newServoId + ); + if (resId !== COMM_SUCCESS) { + throw new Error( + `Failed to write new ID ${newServoId} to servo ${currentServoId}: ${packetHandler.getTxRxResult( + resId + )}, Error: ${errId}` + ); + } + idWritten = true; + + // 3. Lock servo configuration (using NEW ID) + const [resLock, errLock] = await packetHandler.write1ByteTxRx( + portHandler, + newServoId, // Use NEW ID here + ADDR_SCS_LOCK, + 1 // 1 to lock + ); + if (resLock !== COMM_SUCCESS) { + // ID was likely changed, but lock failed. Critical state. + throw new Error( + `Failed to lock servo with new ID ${newServoId}: ${packetHandler.getTxRxResult( + resLock + )}, Error: ${errLock}. Configuration might be incomplete.` + ); + } + unlocked = false; // Successfully locked with new ID + + console.log( + `Successfully set servo ID from ${currentServoId} to ${newServoId}. Remember to use the new ID for future commands.` + ); + return "success"; + } catch (err) { + console.error( + `Exception during setServoId for current ID ${currentServoId}:`, + err + ); + if (unlocked) { + // If unlock succeeded but subsequent steps failed, attempt to re-lock. + // If ID write failed, use current ID. If ID write succeeded but lock failed, use new ID. + const idToLock = idWritten ? newServoId : currentServoId; + console.warn(`Attempting to re-lock servo using ID ${idToLock}...`); + await tryLockServo(idToLock); + } + throw new Error( + `Failed to set servo ID from ${currentServoId} to ${newServoId}: ${err.message}` + ); + } +} diff --git a/src/lib/feetech.js/scsServoSDKUnlock.mjs b/src/lib/feetech.js/scsServoSDKUnlock.mjs new file mode 100644 index 0000000000000000000000000000000000000000..31093fd28bea0aaadabdb55f59e4251cbea996b8 --- /dev/null +++ b/src/lib/feetech.js/scsServoSDKUnlock.mjs @@ -0,0 +1,235 @@ +/** + * Read-Only Servo SDK for USB Master + * Simplified version that only reads positions without any locking + */ + +import { + PortHandler, + PacketHandler, + COMM_SUCCESS, + GroupSyncRead, +} from "./lowLevelSDK.mjs"; + +import { + ADDR_SCS_PRESENT_POSITION, +} from "./scsservo_constants.mjs"; + +// Module-level variables for handlers +let portHandler = null; +let packetHandler = null; + +/** + * Connects to the serial port and initializes handlers. + * @param {object} [options] - Connection options. + * @param {number} [options.baudRate=1000000] - The baud rate for the serial connection. + * @param {number} [options.protocolEnd=0] - The protocol end setting (0 for STS/SMS, 1 for SCS). + * @returns {Promise} Resolves with true on successful connection. + * @throws {Error} If connection fails or port cannot be opened/selected. + */ +export async function connect(options = {}) { + if (portHandler && portHandler.isOpen) { + console.log("๐Ÿ”“ Already connected to USB robot (read-only mode)."); + return true; + } + + const { baudRate = 1000000, protocolEnd = 0 } = options; + + try { + portHandler = new PortHandler(); + const portRequested = await portHandler.requestPort(); + if (!portRequested) { + portHandler = null; + throw new Error("Failed to select a serial port."); + } + + portHandler.setBaudRate(baudRate); + const portOpened = await portHandler.openPort(); + if (!portOpened) { + await portHandler.closePort().catch(console.error); + portHandler = null; + throw new Error(`Failed to open port at baudrate ${baudRate}.`); + } + + packetHandler = new PacketHandler(protocolEnd); + console.log( + `๐Ÿ”“ Connected to USB robot (read-only mode) at ${baudRate} baud, protocol end: ${protocolEnd}.` + ); + return true; + } catch (err) { + console.error("Error during USB robot connection:", err); + if (portHandler) { + try { + await portHandler.closePort(); + } catch (closeErr) { + console.error("Error closing port after connection failure:", closeErr); + } + } + portHandler = null; + packetHandler = null; + throw new Error(`USB robot connection failed: ${err.message}`); + } +} + +/** + * Disconnects from the serial port. + * @returns {Promise} Resolves with true on successful disconnection. + * @throws {Error} If disconnection fails. + */ +export async function disconnect() { + if (!portHandler || !portHandler.isOpen) { + console.log("Already disconnected from USB robot."); + return true; + } + + try { + await portHandler.closePort(); + portHandler = null; + packetHandler = null; + console.log("๐Ÿ”“ Disconnected from USB robot (read-only mode)."); + return true; + } catch (err) { + console.error("Error during USB robot disconnection:", err); + portHandler = null; + packetHandler = null; + throw new Error(`USB robot disconnection failed: ${err.message}`); + } +} + +/** + * Checks if the SDK is connected. Throws an error if not. + * @throws {Error} If not connected. + */ +function checkConnection() { + if (!portHandler || !packetHandler) { + throw new Error("Not connected to USB robot. Call connect() first."); + } +} + +/** + * Reads the current position of a servo. + * @param {number} servoId - The ID of the servo (1-252). + * @returns {Promise} Resolves with the position (0-4095). + * @throws {Error} If not connected, read fails, or an exception occurs. + */ +export async function readPosition(servoId) { + checkConnection(); + try { + const [position, result, error] = await packetHandler.read2ByteTxRx( + portHandler, + servoId, + ADDR_SCS_PRESENT_POSITION + ); + + if (result !== COMM_SUCCESS) { + throw new Error( + `Error reading position from servo ${servoId}: ${packetHandler.getTxRxResult( + result + )}, Error code: ${error}` + ); + } + return position & 0xffff; + } catch (err) { + console.error(`Exception reading position from servo ${servoId}:`, err); + throw new Error( + `Exception reading position from servo ${servoId}: ${err.message}` + ); + } +} + +/** + * Reads the current position of multiple servos synchronously. + * Returns positions for all servos that respond, skipping failed ones gracefully. + * @param {number[]} servoIds - An array of servo IDs (1-252) to read from. + * @returns {Promise>} Resolves with a Map where keys are servo IDs and values are positions (0-4095). + * @throws {Error} If not connected or transmission fails completely. + */ +export async function syncReadPositions(servoIds) { + checkConnection(); + if (!Array.isArray(servoIds) || servoIds.length === 0) { + console.log("Sync Read: No servo IDs provided."); + return new Map(); + } + + const startAddress = ADDR_SCS_PRESENT_POSITION; + const dataLength = 2; + const groupSyncRead = new GroupSyncRead( + portHandler, + packetHandler, + startAddress, + dataLength + ); + const positions = new Map(); + const validIds = []; + + // Add parameters for each valid servo ID + servoIds.forEach((id) => { + if (id >= 1 && id <= 252) { + if (groupSyncRead.addParam(id)) { + validIds.push(id); + } else { + console.warn( + `Sync Read: Failed to add param for servo ID ${id} (maybe duplicate or invalid).` + ); + } + } else { + console.warn(`Sync Read: Invalid servo ID ${id} skipped.`); + } + }); + + if (validIds.length === 0) { + console.log("Sync Read: No valid servo IDs to read."); + return new Map(); + } + + try { + // Send the Sync Read instruction packet + let txResult = await groupSyncRead.txPacket(); + if (txResult !== COMM_SUCCESS) { + throw new Error( + `Sync Read txPacket failed: ${packetHandler.getTxRxResult(txResult)}` + ); + } + + // Receive the response packets + let rxResult = await groupSyncRead.rxPacket(); + if (rxResult !== COMM_SUCCESS) { + console.warn( + `Sync Read rxPacket overall result: ${packetHandler.getTxRxResult( + rxResult + )}. Checking individual servos.` + ); + } + + // Check data availability and retrieve data for each servo + const failedIds = []; + validIds.forEach((id) => { + const isAvailable = groupSyncRead.isAvailable( + id, + startAddress, + dataLength + ); + if (isAvailable) { + const position = groupSyncRead.getData(id, startAddress, dataLength); + const finalPosition = position & 0xffff; + console.log(`๐Ÿ” Debug Servo ${id}: raw=${position}, final=${finalPosition}, hex=${position.toString(16)}`); + positions.set(id, finalPosition); + } else { + failedIds.push(id); + } + }); + + // Log failed servos but don't throw error - return available data + if (failedIds.length > 0) { + console.warn( + `Sync Read: Data not available for servo IDs: ${failedIds.join( + ", " + )}. Got ${positions.size}/${validIds.length} servos successfully.` + ); + } + + return positions; + } catch (err) { + console.error("Exception during syncReadPositions:", err); + throw new Error(`Sync Read failed: ${err.message}`); + } +} \ No newline at end of file diff --git a/src/lib/feetech.js/scsservo_constants.mjs b/src/lib/feetech.js/scsservo_constants.mjs new file mode 100644 index 0000000000000000000000000000000000000000..350aa2a82fc1a44acb263e2fa8481ee368bceb9f --- /dev/null +++ b/src/lib/feetech.js/scsservo_constants.mjs @@ -0,0 +1,53 @@ +// Constants for FeetTech SCS servos + +// Constants +export const BROADCAST_ID = 0xFE; // 254 +export const MAX_ID = 0xFC; // 252 + +// Protocol instructions +export const INST_PING = 1; +export const INST_READ = 2; +export const INST_WRITE = 3; +export const INST_REG_WRITE = 4; +export const INST_ACTION = 5; +export const INST_SYNC_WRITE = 131; // 0x83 +export const INST_SYNC_READ = 130; // 0x82 +export const INST_STATUS = 85; // 0x55, status packet instruction (0x55) + +// Communication results +export const COMM_SUCCESS = 0; // tx or rx packet communication success +export const COMM_PORT_BUSY = -1; // Port is busy (in use) +export const COMM_TX_FAIL = -2; // Failed transmit instruction packet +export const COMM_RX_FAIL = -3; // Failed get status packet +export const COMM_TX_ERROR = -4; // Incorrect instruction packet +export const COMM_RX_WAITING = -5; // Now receiving status packet +export const COMM_RX_TIMEOUT = -6; // There is no status packet +export const COMM_RX_CORRUPT = -7; // Incorrect status packet +export const COMM_NOT_AVAILABLE = -9; + +// Packet constants +export const TXPACKET_MAX_LEN = 250; +export const RXPACKET_MAX_LEN = 250; + +// Protocol Packet positions +export const PKT_HEADER0 = 0; +export const PKT_HEADER1 = 1; +export const PKT_ID = 2; +export const PKT_LENGTH = 3; +export const PKT_INSTRUCTION = 4; +export const PKT_ERROR = 4; +export const PKT_PARAMETER0 = 5; + +// Protocol Error bits +export const ERRBIT_VOLTAGE = 1; +export const ERRBIT_ANGLE = 2; +export const ERRBIT_OVERHEAT = 4; +export const ERRBIT_OVERELE = 8; +export const ERRBIT_OVERLOAD = 32; + +// Control table addresses (SCS servos) +export const ADDR_SCS_TORQUE_ENABLE = 40; +export const ADDR_SCS_GOAL_ACC = 41; +export const ADDR_SCS_GOAL_POSITION = 42; +export const ADDR_SCS_GOAL_SPEED = 46; +export const ADDR_SCS_PRESENT_POSITION = 56; \ No newline at end of file diff --git a/src/lib/feetech.js/test.html b/src/lib/feetech.js/test.html new file mode 100644 index 0000000000000000000000000000000000000000..54aa49a0b65269216a97e8f683393eceda41261b --- /dev/null +++ b/src/lib/feetech.js/test.html @@ -0,0 +1,619 @@ + + + + + + Feetech Servo Test + + + +
+

Feetech Servo Test Page

+ +
+ Key Concepts +

Understanding these parameters is crucial for controlling Feetech servos:

+
    +
  • + Mode: Determines the servo's primary function. +
      +
    • Mode 0: Position/Servo Mode. The servo moves to and holds a specific angular position.
    • +
    • Mode 1: Wheel/Speed Mode. The servo rotates continuously at a specified speed and direction, like a motor.
    • +
    + Changing the mode requires unlocking, writing the mode value (0 or 1), and locking the configuration. +
  • +
  • + Position: In Position Mode (Mode 0), this value represents the target or current angular position of the servo's output shaft. +
      +
    • Range: Typically 0 to 4095 (representing a 12-bit resolution).
    • +
    • Meaning: Corresponds to the servo's rotational range (e.g., 0-360 degrees or 0-270 degrees, depending on the specific servo model). 0 is one end of the range, 4095 is the other.
    • +
    +
  • +
  • + Speed (Wheel Mode): In Wheel Mode (Mode 1), this value controls the rotational speed and direction. +
      +
    • Range: Typically -2500 to +2500. (Note: Some documentation might mention -1023 to +1023, but the SDK example uses a wider range).
    • +
    • Meaning: 0 stops the wheel. Positive values rotate in one direction (e.g., clockwise), negative values rotate in the opposite direction (e.g., counter-clockwise). The magnitude determines the speed (larger absolute value means faster rotation).
    • +
    • Control Address: ADDR_SCS_GOAL_SPEED (Register 46/47).
    • +
    +
  • +
  • + Acceleration: Controls how quickly the servo changes speed to reach its target position (in Position Mode) or target speed (in Wheel Mode). +
      +
    • Range: Typically 0 to 254.
    • +
    • Meaning: Defines the rate of change of speed. The unit is 100 steps/sยฒ. 0 usually means instantaneous acceleration (or minimal delay). Higher values result in slower, smoother acceleration and deceleration. For example, a value of 10 means the speed changes by 10 * 100 = 1000 steps per second, per second. This helps reduce jerky movements and mechanical stress.
    • +
    • Control Address: ADDR_SCS_GOAL_ACC (Register 41).
    • +
    +
  • +
  • + Baud Rate: The speed of communication between the controller and the servo. It must match on both ends. Servos often support multiple baud rates, selectable via an index: +
      +
    • Index 0: 1,000,000 bps
    • +
    • Index 1: 500,000 bps
    • +
    • Index 2: 250,000 bps
    • +
    • Index 3: 128,000 bps
    • +
    • Index 4: 115,200 bps
    • +
    • Index 5: 76,800 bps
    • +
    • Index 6: 57,600 bps
    • +
    • Index 7: 38,400 bps
    • +
    +
  • +
+
+ + +
+

Connection

+ + +

Status: Disconnected

+ + + + +
+ +
+

Scan Servos

+ + + + + +

Scan Results:

+
 
+        
+ +
+

Single Servo Control

+ +
+ + + + +
+ + + +
+ + + +
+ + + +
+ + + +
+ + + +
+ + + +
+ + + +
+ + + + +
+ +
+

Sync Operations

+ + +
+ + + +
+ + + + +
+ + +
+

Log Output

+

+        
+
+ + + + diff --git a/src/lib/index.ts b/src/lib/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..856f2b6c38aec1085db88189bcf492dbb49a1c45 --- /dev/null +++ b/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/src/lib/robot/Robot.svelte.ts b/src/lib/robot/Robot.svelte.ts new file mode 100644 index 0000000000000000000000000000000000000000..eac15aea9988ddc9caf21c3c644b10c2b9acea27 --- /dev/null +++ b/src/lib/robot/Robot.svelte.ts @@ -0,0 +1,696 @@ +import type { RobotState } from '$lib/types/robot'; +import type { + MasterDriver, + SlaveDriver, + DriverJointState, + ConnectionStatus, + RobotCommand, + CommandSequence, + UnsubscribeFn +} from '$lib/types/robotDriver'; +import type IUrdfJoint from '$lib/components/scene/robot/URDF/interfaces/IUrdfJoint'; +import { getRobotPollingConfig } from '$lib/configs/performanceConfig'; + +/** + * Enhanced joint state that combines URDF data with driver state + */ +export interface ManagedJointState { + // URDF properties + name: string; + urdfJoint: IUrdfJoint; + servoId?: number; + + // Current state + virtualValue: number; // What the UI shows/controls + realValue?: number; // What the physical robot reports + commandedValue: number; // What was last commanded by master/manual + isActive: boolean; // Whether this joint is being controlled + + // Calibration state + calibrationOffset: number; // Offset to apply: realValue = rawValue + offset + restPosition: number; // Expected position for this joint in rest mode + + // Sync state + lastVirtualUpdate: Date; + lastRealUpdate?: Date; + lastCommandUpdate?: Date; + syncError?: string; +} + +/** + * Calibration state for robot + */ +export interface CalibrationState { + isCalibrated: boolean; + calibrationTime?: Date; + offsets: { [jointName: string]: number }; +} + +/** + * Control source information + */ +export interface ControlState { + hasActiveMaster: boolean; + masterName?: string; + manualControlEnabled: boolean; + lastCommandSource: "master" | "manual" | "none"; + commandQueue: RobotCommand[]; +} + +/** + * Robot instance that manages URDF visualization and master-slave connections + */ +export class Robot { + readonly id: string; + readonly robotState: RobotState; + + // Joint management + private _joints = $state([]); + + // Master driver (command source) + private _master = $state(undefined); + private _masterStatus = $state({ isConnected: false }); + + // Slave drivers (execution targets) + private _slaves = $state([]); + private _slaveStatuses = $state>(new Map()); + + // Control state + private _controlState = $state({ + hasActiveMaster: false, + manualControlEnabled: true, + lastCommandSource: "none", + commandQueue: [] + }); + + // Calibration management + private _calibrationState = $state({ + isCalibrated: false, + offsets: {} + }); + + // Sync control - use performance config + private syncIntervalId?: number; + private readonly syncInterval = getRobotPollingConfig().ROBOT_SYNC_INTERVAL_MS; + + // Event subscriptions + private unsubscribeCallbacks: UnsubscribeFn[] = []; + + // Reactive getters + get joints() { return this._joints; } + get master() { return this._master; } + get masterStatus() { return this._masterStatus; } + get slaves() { return this._slaves; } + get slaveStatuses() { return this._slaveStatuses; } + get controlState() { return this._controlState; } + get calibrationState() { return this._calibrationState; } + get isCalibrated() { return this._calibrationState.isCalibrated; } + + get activeJoints() { + return this._joints.filter(joint => joint.isActive); + } + + get connectedSlaves() { + return this._slaves.filter(slave => this._slaveStatuses.get(slave.id)?.isConnected); + } + + get manualControlEnabled() { + return this._controlState.manualControlEnabled && !this._controlState.hasActiveMaster; + } + + constructor(id: string, robotState: RobotState) { + this.id = id; + this.robotState = robotState; + this.initializeJoints(); + } + + /** + * Initialize joint states from URDF + */ + private initializeJoints(): void { + const servoMap = this.robotState.urdfConfig.jointNameIdMap || {}; + const restPositions = this.robotState.urdfConfig.restPosition || {}; + + this._joints = this.robotState.robot.joints + .filter(urdfJoint => + urdfJoint.type === "revolute" || urdfJoint.type === "continuous" + ) + .map(urdfJoint => { + const jointName = urdfJoint.name || ""; + const restPosition = restPositions[jointName] || 0; + + // Initialize URDF joint rotation to rest position + if (urdfJoint.type === "revolute") { + const radians = (restPosition * Math.PI) / 180; + const axis = urdfJoint.axis_xyz || [0, 0, 1]; + urdfJoint.rotation = [ + radians * axis[0], + radians * axis[1], + radians * axis[2] + ]; + } + + return { + name: jointName, + urdfJoint, + servoId: servoMap[jointName], + virtualValue: restPosition, + commandedValue: restPosition, + realValue: undefined, + isActive: !!servoMap[jointName], + lastVirtualUpdate: new Date(), + lastRealUpdate: undefined, + lastCommandUpdate: undefined, + syncError: undefined, + calibrationOffset: 0, + restPosition, + }; + }); + + console.log(`Initialized ${this._joints.length} joints for robot ${this.id}`); + } + + // ============= MASTER MANAGEMENT ============= + + /** + * Connect a master driver (command source) + */ + async setMaster(master: MasterDriver): Promise { + // Disconnect existing master + if (this._master) { + await this.removeMaster(); + } + + this._master = master; + this._masterStatus = master.status; + + // Subscribe to master events + const statusUnsub = master.onStatusChange((status) => { + this._masterStatus = status; + this._controlState.hasActiveMaster = status.isConnected; + this._controlState.manualControlEnabled = !status.isConnected; + }); + + const commandUnsub = master.onCommand((commands) => { + this.handleMasterCommands(commands); + }); + + const sequenceUnsub = master.onSequence((sequence) => { + this.handleMasterSequence(sequence); + }); + + this.unsubscribeCallbacks.push(statusUnsub, commandUnsub, sequenceUnsub); + + // Update control state + this._controlState.hasActiveMaster = master.status.isConnected; + this._controlState.manualControlEnabled = !master.status.isConnected; + this._controlState.masterName = master.name; + + // Start the master (for masters that need to poll/generate commands) + if (master.status.isConnected) { + await master.start(); + } + + console.log(`Master ${master.name} connected to robot ${this.id}`); + } + + /** + * Remove the current master + */ + async removeMaster(): Promise { + if (!this._master) return; + + if (this._master.status.isConnected) { + // Stop the master first, then disconnect + try { + await this._master.stop(); + } catch (error) { + console.warn(`Error stopping master ${this._master.name}:`, error); + } + + await this._master.disconnect(); + } + + this._master = undefined; + this._masterStatus = { isConnected: false }; + this._controlState.hasActiveMaster = false; + this._controlState.manualControlEnabled = true; + this._controlState.masterName = undefined; + + console.log(`Master removed from robot ${this.id}. Manual control restored.`); + } + + // ============= SLAVE MANAGEMENT ============= + + /** + * Add a slave driver (execution target) + */ + async addSlave(slave: SlaveDriver): Promise { + // Check if slave already exists + if (this._slaves.find(s => s.id === slave.id)) { + throw new Error(`Slave ${slave.id} already connected to robot ${this.id}`); + } + + // Connect the slave + await slave.connect(); + + this._slaves.push(slave); + this._slaveStatuses.set(slave.id, slave.status); + + // Subscribe to slave events + const statusUnsub = slave.onStatusChange((status) => { + this._slaveStatuses.set(slave.id, status); + }); + + const stateUnsub = slave.onStateUpdate((driverStates) => { + this.updateFromSlave(slave.id, driverStates); + }); + + this.unsubscribeCallbacks.push(statusUnsub, stateUnsub); + + // Move slave to rest position for safety + await this.moveSlaveToRestPosition(slave); + + console.log(`Slave ${slave.name} (${slave.id}) connected to robot ${this.id}`); + } + + /** + * Remove a slave driver + */ + async removeSlave(slaveId: string): Promise { + const slaveIndex = this._slaves.findIndex(s => s.id === slaveId); + if (slaveIndex === -1) return; + + const slave = this._slaves[slaveIndex]; + + // Move to rest position before disconnection + if (this._slaveStatuses.get(slaveId)?.isConnected) { + try { + await this.moveSlaveToRestPosition(slave); + await new Promise(resolve => setTimeout(resolve, 500)); + } catch (error) { + console.warn(`Failed to move slave ${slaveId} to rest position:`, error); + } + } + + // Disconnect slave + if (slave.status.isConnected) { + await slave.disconnect(); + } + + // Remove from arrays + this._slaves.splice(slaveIndex, 1); + this._slaveStatuses.delete(slaveId); + + console.log(`Slave ${slaveId} removed from robot ${this.id}`); + } + + // ============= COMMAND HANDLING ============= + + /** + * Handle commands from master + */ + private async handleMasterCommands(commands: RobotCommand[]): Promise { + console.log(`Received ${commands.length} commands from master for robot ${this.id}`); + + // Update control state + this._controlState.lastCommandSource = "master"; + this._controlState.commandQueue = [...commands]; + + // Execute on all connected slaves + const executePromises = this.connectedSlaves.map(slave => + slave.executeCommands(commands).catch(error => + console.error(`Slave ${slave.id} failed to execute commands:`, error) + ) + ); + + await Promise.allSettled(executePromises); + + // Update virtual state from commands + this.updateVirtualFromCommands(commands); + } + + /** + * Handle command sequence from master + */ + private async handleMasterSequence(sequence: CommandSequence): Promise { + console.log(`Received sequence "${sequence.name}" from master for robot ${this.id}`); + + // Process sequence commands one by one + for (const command of sequence.commands) { + await this.handleMasterCommands([command]); + + // Wait for command duration if specified + if (command.duration) { + await new Promise(resolve => setTimeout(resolve, command.duration)); + } + } + } + + /** + * Manual control - update joint value (when no master active) + */ + async updateJointValue(jointName: string, value: number): Promise { + if (!this.manualControlEnabled) { + console.warn(`Manual control disabled - master is active for robot ${this.id}`); + return; + } + + const joint = this._joints.find(j => j.name === jointName); + if (!joint) { + console.warn(`Joint ${jointName} not found in robot ${this.id}`); + return; + } + + // Apply limits + const clampedValue = this.clampJointValue(joint, value); + + // Update virtual state + joint.virtualValue = clampedValue; + joint.commandedValue = clampedValue; + joint.lastVirtualUpdate = new Date(); + joint.lastCommandUpdate = new Date(); + + // Update URDF visualization + this.updateUrdfJointRotation(joint, clampedValue); + + // Create command for slaves + const command: RobotCommand = { + timestamp: Date.now(), + joints: [{ name: jointName, value: clampedValue }] + }; + + // Send to all connected slaves + const executePromises = this.connectedSlaves.map(slave => + slave.executeCommand(command).catch(error => + console.error(`Slave ${slave.id} failed to execute manual command:`, error) + ) + ); + + await Promise.allSettled(executePromises); + + this._controlState.lastCommandSource = "manual"; + } + + /** + * Manual control - update multiple joints + */ + async updateJointValues(updates: { jointName: string; value: number }[]): Promise { + if (!this.manualControlEnabled) { + console.warn(`Manual control disabled - master is active for robot ${this.id}`); + return; + } + + // Update virtual states + const commandJoints: { name: string; value: number }[] = []; + + for (const { jointName, value } of updates) { + const joint = this._joints.find(j => j.name === jointName); + if (!joint) continue; + + const clampedValue = this.clampJointValue(joint, value); + joint.virtualValue = clampedValue; + joint.commandedValue = clampedValue; + joint.lastVirtualUpdate = new Date(); + joint.lastCommandUpdate = new Date(); + + // Update URDF visualization + this.updateUrdfJointRotation(joint, clampedValue); + + commandJoints.push({ name: jointName, value: clampedValue }); + } + + if (commandJoints.length === 0) return; + + // Create batch command for slaves + const command: RobotCommand = { + timestamp: Date.now(), + joints: commandJoints + }; + + // Send to all connected slaves + const executePromises = this.connectedSlaves.map(slave => + slave.executeCommand(command).catch(error => + console.error(`Slave ${slave.id} failed to execute manual batch command:`, error) + ) + ); + + await Promise.allSettled(executePromises); + + this._controlState.lastCommandSource = "manual"; + } + + // ============= STATE MANAGEMENT ============= + + /** + * Update virtual state from master commands + */ + private updateVirtualFromCommands(commands: RobotCommand[]): void { + for (const command of commands) { + for (const jointCommand of command.joints) { + const joint = this._joints.find(j => j.name === jointCommand.name); + if (joint) { + joint.virtualValue = jointCommand.value; + joint.commandedValue = jointCommand.value; + joint.lastCommandUpdate = new Date(); + + // Update URDF visualization + this.updateUrdfJointRotation(joint, jointCommand.value); + } + } + } + } + + /** + * Update joint states from slave feedback + */ + private updateFromSlave(slaveId: string, driverStates: DriverJointState[]): void { + for (const driverState of driverStates) { + const joint = this._joints.find(j => j.name === driverState.name); + if (joint) { + // Apply calibration offset to raw real value + if (driverState.realValue !== undefined) { + joint.realValue = driverState.realValue + joint.calibrationOffset; + } else { + joint.realValue = undefined; + } + + joint.lastRealUpdate = new Date(); + } + } + } + + /** + * Update URDF joint rotation for 3D visualization + */ + private updateUrdfJointRotation(joint: ManagedJointState, degrees: number): void { + const urdfJoint = joint.urdfJoint; + if (!urdfJoint) return; + + if (urdfJoint.type === "revolute") { + const radians = (degrees * Math.PI) / 180; + const axis = urdfJoint.axis_xyz || [0, 0, 1]; + urdfJoint.rotation = [ + radians * axis[0], + radians * axis[1], + radians * axis[2] + ]; + } + } + + /** + * Apply joint limits to a value + */ + private clampJointValue(joint: ManagedJointState, value: number): number { + const limit = joint.urdfJoint.limit; + if (!limit) return value; + + if (joint.urdfJoint.type === "revolute") { + if (limit.lower !== undefined && limit.upper !== undefined) { + const lowerDeg = (limit.lower * 180) / Math.PI; + const upperDeg = (limit.upper * 180) / Math.PI; + return Math.max(lowerDeg, Math.min(upperDeg, value)); + } + } else if (joint.urdfJoint.type === "continuous") { + if (limit.velocity !== undefined) { + return Math.max(-limit.velocity, Math.min(limit.velocity, value)); + } + } + + return value; + } + + // ============= MOVEMENT FUNCTIONS ============= + + /** + * Move robot to rest position smoothly + */ + async moveToRestPosition(durationMs: number = 3000): Promise { + console.log(`Moving robot ${this.id} to rest position over ${durationMs}ms`); + + const movements: Array<{ + joint: ManagedJointState; + startValue: number; + targetValue: number; + totalDelta: number; + }> = []; + + for (const joint of this._joints) { + if (joint.isActive) { + const startValue = joint.virtualValue; + const targetValue = joint.restPosition; + const totalDelta = targetValue - startValue; + + movements.push({ + joint, + startValue, + targetValue, + totalDelta + }); + } + } + + if (movements.length === 0) { + console.log("No active joints to move"); + return; + } + + const startTime = Date.now(); + const updateInterval = 50; + + return new Promise((resolve) => { + const animationStep = () => { + const elapsed = Date.now() - startTime; + const progress = Math.min(elapsed / durationMs, 1.0); + + const easedProgress = progress < 0.5 + ? 4 * progress * progress * progress + : 1 - Math.pow(-2 * progress + 2, 3) / 2; + + const updates: Array<{ jointName: string; value: number }> = []; + + for (const movement of movements) { + const currentValue = movement.startValue + (movement.totalDelta * easedProgress); + updates.push({ + jointName: movement.joint.name, + value: currentValue + }); + } + + this.updateJointValues(updates); + + if (progress < 1.0) { + setTimeout(animationStep, updateInterval); + } else { + console.log(`Robot ${this.id} reached rest position`); + resolve(); + } + }; + + animationStep(); + }); + } + + /** + * Move a specific slave to rest position + */ + private async moveSlaveToRestPosition(slave: SlaveDriver): Promise { + const restCommand: RobotCommand = { + timestamp: Date.now(), + joints: this._joints.map(joint => ({ + name: joint.name, + value: joint.restPosition + })) + }; + + await slave.executeCommand(restCommand); + } + + // ============= CALIBRATION ============= + + /** + * Calibrate robot using first connected slave + */ + async calibrateRobot(): Promise { + const connectedSlaves = this.connectedSlaves; + if (connectedSlaves.length === 0) { + throw new Error("Cannot calibrate: no slaves connected"); + } + + const primarySlave = connectedSlaves[0]; + console.log(`Calibrating robot ${this.id} using slave ${primarySlave.id}...`); + + try { + const driverStates = await primarySlave.readJointStates(); + const offsets: { [jointName: string]: number } = {}; + + for (const driverState of driverStates) { + const joint = this._joints.find(j => j.name === driverState.name); + if (joint && driverState.realValue !== undefined) { + const offset = joint.restPosition - driverState.realValue; + offsets[joint.name] = offset; + joint.calibrationOffset = offset; + + console.log(`Joint ${joint.name}: rest=${joint.restPosition}ยฐ real=${driverState.realValue}ยฐ offset=${offset.toFixed(1)}ยฐ`); + } + } + + this._calibrationState = { + isCalibrated: true, + calibrationTime: new Date(), + offsets + }; + + console.log(`Robot ${this.id} calibrated successfully`); + + } catch (error) { + console.error(`Calibration failed for robot ${this.id}:`, error); + throw error; + } + } + + /** + * Clear calibration + */ + clearCalibration(): void { + this._calibrationState = { + isCalibrated: false, + offsets: {} + }; + + this._joints.forEach(joint => { + joint.calibrationOffset = 0; + }); + + console.log(`Cleared calibration for robot ${this.id}`); + } + + // ============= LIFECYCLE ============= + + /** + * Get joint by name + */ + getJoint(name: string): ManagedJointState | undefined { + return this._joints.find(j => j.name === name); + } + + /** + * Clean up resources + */ + async destroy(): Promise { + // Clean up subscriptions + this.unsubscribeCallbacks.forEach(unsub => unsub()); + this.unsubscribeCallbacks = []; + + // Remove master + await this.removeMaster(); + + // Remove all slaves + const slaveIds = this._slaves.map(s => s.id); + for (const slaveId of slaveIds) { + await this.removeSlave(slaveId); + } + + console.log(`Robot ${this.id} destroyed`); + } +} \ No newline at end of file diff --git a/src/lib/robot/RobotManager.svelte.ts b/src/lib/robot/RobotManager.svelte.ts new file mode 100644 index 0000000000000000000000000000000000000000..1a8a65e03e496e2c72d2b19334490d35e50be3fa --- /dev/null +++ b/src/lib/robot/RobotManager.svelte.ts @@ -0,0 +1,362 @@ +import { Robot, type ManagedJointState } from './Robot.svelte'; +import type { + MasterDriver, + SlaveDriver, + DriverJointState, + MasterDriverConfig, + SlaveDriverConfig +} from '$lib/types/robotDriver'; +import { MockSequenceMaster, DEMO_SEQUENCES } from './drivers/MockSequenceMaster'; +import { MockSlave } from './drivers/MockSlave'; +import { USBSlave } from './drivers/USBSlave'; +import { RemoteServerMaster } from './drivers/RemoteServerMaster'; +import { RemoteServerSlave } from './drivers/RemoteServerSlave'; +import { USBMaster } from './drivers/USBMaster'; +// import { RemoteServerMaster } from './drivers/RemoteServerMaster'; // TODO: Implement +import { createRobot } from '$lib/components/scene/robot/URDF/createRobot.svelte'; +import type { RobotUrdfConfig } from '$lib/types/urdf'; +import { getCommunicationConfig, getRobotPollingConfig, getDataProcessingConfig } from '$lib/configs/performanceConfig'; + +/** + * Central manager for all robots with master-slave architecture + * + * Masters: Command sources (remote servers, scripts, manual control) + * Slaves: Execution targets (physical robots, simulators) + */ +export class RobotManager { + private _robots = $state([]); + + // Reactive getters + get robots(): Robot[] { + return this._robots; + } + + get robotCount(): number { + return this._robots.length; + } + + get robotsWithMaster(): Robot[] { + return this._robots.filter(robot => robot.master !== undefined); + } + + get robotsWithSlaves(): Robot[] { + return this._robots.filter(robot => robot.slaves.length > 0); + } + + /** + * Create a new robot from URDF configuration + */ + async createRobot(id: string, urdfConfig: RobotUrdfConfig): Promise { + // Check if robot already exists + if (this._robots.find(r => r.id === id)) { + throw new Error(`Robot with ID ${id} already exists`); + } + + // Create robot state from URDF + const robotState = await createRobot(urdfConfig); + + // Create managed robot + const robot = new Robot(id, robotState); + + // Add to reactive array + this._robots.push(robot); + + console.log(`Created robot ${id}. Total robots: ${this._robots.length}`); + + return robot; + } + + /** + * Get robot by ID + */ + getRobot(id: string): Robot | undefined { + return this._robots.find(r => r.id === id); + } + + /** + * Remove a robot + */ + async removeRobot(id: string): Promise { + const robotIndex = this._robots.findIndex(r => r.id === id); + if (robotIndex === -1) return; + + const robot = this._robots[robotIndex]; + + // Move to rest position before removal if has connected slaves + if (robot.connectedSlaves.length > 0) { + try { + console.log(`Removing robot ${id}: moving to rest position first`); + await robot.moveToRestPosition(3000); + await new Promise(resolve => setTimeout(resolve, 500)); + } catch (error) { + console.warn(`Failed to move robot ${id} to rest position before removal:`, error); + } + } + + // Clean up robot resources + await robot.destroy(); + + // Remove from reactive array + this._robots.splice(robotIndex, 1); + + console.log(`Removed robot ${id}. Remaining robots: ${this._robots.length}`); + } + + // ============= MASTER MANAGEMENT ============= + + /** + * Connect a master driver to a robot + */ + async connectMaster(robotId: string, masterConfig: MasterDriverConfig): Promise { + const robot = this._robots.find(r => r.id === robotId); + if (!robot) { + throw new Error(`Robot ${robotId} not found`); + } + + // Create master driver instance + const master = this.createMaster(masterConfig, robotId); + + // Connect the master + await master.connect(); + + // Attach to robot + await robot.setMaster(master); + + console.log(`Master ${master.name} connected to robot ${robotId}`); + } + + /** + * Disconnect a robot's master + */ + async disconnectMaster(robotId: string): Promise { + const robot = this._robots.find(r => r.id === robotId); + if (!robot) { + throw new Error(`Robot ${robotId} not found`); + } + + await robot.removeMaster(); + console.log(`Master disconnected from robot ${robotId}. Manual control restored.`); + } + + // ============= SLAVE MANAGEMENT ============= + + /** + * Connect a slave driver to a robot + */ + async connectSlave(robotId: string, slaveConfig: SlaveDriverConfig): Promise { + const robot = this._robots.find(r => r.id === robotId); + if (!robot) { + throw new Error(`Robot ${robotId} not found`); + } + + // Create slave driver instance + const slave = this.createSlave(slaveConfig, robot); + + // Add to robot (this handles connection and initialization) + await robot.addSlave(slave); + + console.log(`Slave ${slave.name} connected to robot ${robotId}`); + } + + /** + * Disconnect a slave driver from a robot + */ + async disconnectSlave(robotId: string, slaveId: string): Promise { + const robot = this._robots.find(r => r.id === robotId); + if (!robot) { + throw new Error(`Robot ${robotId} not found`); + } + + await robot.removeSlave(slaveId); + console.log(`Slave ${slaveId} disconnected from robot ${robotId}`); + } + + // ============= CONVENIENCE METHODS ============= + + /** + * Connect demo sequence master to a robot + */ + async connectDemoSequences(robotId: string, loopMode: boolean = true): Promise { + const config: MasterDriverConfig = { + type: "mock-sequence", + sequences: DEMO_SEQUENCES, + autoStart: true, + loopMode + }; + + await this.connectMaster(robotId, config); + } + + /** + * Connect mock slave to a robot + */ + async connectMockSlave(robotId: string, simulateLatency: number = 50): Promise { + const config: SlaveDriverConfig = { + type: "mock-slave", + simulateLatency, + simulateErrors: false, + responseDelay: 20 + }; + + await this.connectSlave(robotId, config); + } + + /** + * Connect USB slave to a robot (when implemented) + */ + async connectUSBSlave(robotId: string, port?: string): Promise { + const config: SlaveDriverConfig = { + type: "usb-slave", + port, + baudRate: 115200 + }; + + await this.connectSlave(robotId, config); + } + + /** + * Connect remote server slave to a robot + */ + async connectRemoteServerSlave(robotId: string, url: string = "ws://localhost:8080", apiKey?: string, targetRobotId?: string): Promise { + const config: SlaveDriverConfig = { + type: "remote-server-slave", + url, + apiKey, + robotId: targetRobotId || robotId // Use targetRobotId if provided, otherwise use local robotId + }; + + await this.connectSlave(robotId, config); + } + + /** + * Connect USB master to a robot + */ + async connectUSBMaster(robotId: string, options: { port?: string; baudRate?: number; pollInterval?: number; smoothing?: boolean } = {}): Promise { + const config: MasterDriverConfig = { + type: "usb-master", + port: options.port, + baudRate: options.baudRate || getCommunicationConfig().USB_BAUD_RATE, + pollInterval: options.pollInterval || getRobotPollingConfig().USB_MASTER_POLL_INTERVAL_MS, + smoothing: options.smoothing ?? getDataProcessingConfig().ENABLE_SMOOTHING + }; + + await this.connectMaster(robotId, config); + } + + /** + * Get detailed robot status + */ + getRobotStatus(robotId: string): { + id: string; + hasActiveMaster: boolean; + masterName?: string; + manualControlEnabled: boolean; + connectedSlaves: number; + totalSlaves: number; + lastCommandSource: string; + } | undefined { + const robot = this._robots.find(r => r.id === robotId); + if (!robot) return undefined; + + return { + id: robot.id, + hasActiveMaster: robot.controlState.hasActiveMaster, + masterName: robot.controlState.masterName, + manualControlEnabled: robot.manualControlEnabled, + connectedSlaves: robot.connectedSlaves.length, + totalSlaves: robot.slaves.length, + lastCommandSource: robot.controlState.lastCommandSource + }; + } + + /** + * Get joint states from all robots + */ + getAllJointStates(): { robotId: string; joints: ManagedJointState[] }[] { + return this._robots.map(robot => ({ + robotId: robot.id, + joints: robot.joints + })); + } + + /** + * Clean up all robots + */ + async destroy(): Promise { + const cleanupPromises = this._robots.map(robot => robot.destroy()); + await Promise.allSettled(cleanupPromises); + this._robots.length = 0; + } + + // ============= DRIVER FACTORIES ============= + + /** + * Create a master driver instance + */ + private createMaster(config: MasterDriverConfig, robotId: string): MasterDriver { + switch (config.type) { + case "mock-sequence": + return new MockSequenceMaster(config); + + case "remote-server": + return new RemoteServerMaster(config, robotId); + + case "script-player": + // TODO: Implement ScriptPlayerMaster + throw new Error("Script player master not implemented yet"); + + case "usb-master": + return new USBMaster(config); + + default: { + // TypeScript exhaustiveness check + const _exhaustive: never = config; + throw new Error(`Unknown master driver type: ${(_exhaustive as unknown as { type: string }).type}`); + } + } + } + + /** + * Create a slave driver instance + */ + private createSlave(config: SlaveDriverConfig, robot: Robot): SlaveDriver { + // Convert robot joints to driver joint states + const driverJointStates: DriverJointState[] = robot.joints.map(joint => ({ + name: joint.name, + servoId: joint.servoId || 0, + type: joint.urdfJoint.type as "revolute" | "continuous", + virtualValue: joint.virtualValue, + realValue: joint.realValue, + limits: joint.urdfJoint.limit ? { + lower: joint.urdfJoint.limit.lower, + upper: joint.urdfJoint.limit.upper, + velocity: joint.urdfJoint.limit.velocity, + effort: joint.urdfJoint.limit.effort, + } : undefined, + })); + + switch (config.type) { + case "mock-slave": + return new MockSlave(config, driverJointStates); + + case "usb-slave": + return new USBSlave(config, driverJointStates); + + case "simulation-slave": + // TODO: Implement SimulationSlave + throw new Error("Simulation slave driver not implemented yet"); + + case "remote-server-slave": + return new RemoteServerSlave(config, driverJointStates); + + default: { + // TypeScript exhaustiveness check + const _exhaustive: never = config; + throw new Error(`Unknown slave driver type: ${(_exhaustive as unknown as { type: string }).type}`); + } + } + } +} + +// Global robot manager instance +export const robotManager = new RobotManager(); \ No newline at end of file diff --git a/src/lib/robot/drivers/MockSequenceMaster.ts b/src/lib/robot/drivers/MockSequenceMaster.ts new file mode 100644 index 0000000000000000000000000000000000000000..687bf335ac93f0937686bd9ed0a5f518a3035c20 --- /dev/null +++ b/src/lib/robot/drivers/MockSequenceMaster.ts @@ -0,0 +1,372 @@ +import type { + MasterDriver, + ConnectionStatus, + RobotCommand, + CommandSequence, + MockSequenceMasterConfig, + CommandCallback, + SequenceCallback, + StatusChangeCallback, + UnsubscribeFn +} from '$lib/types/robotDriver'; +import { getRobotPollingConfig } from '$lib/configs/performanceConfig'; + +/** + * Mock Sequence Master Driver + * Provides predefined movement sequences for testing master-slave architecture + */ +export class MockSequenceMaster implements MasterDriver { + readonly type = "master" as const; + readonly id: string; + readonly name: string; + + private _status: ConnectionStatus = { isConnected: false }; + private config: MockSequenceMasterConfig; + + // Event callbacks + private commandCallbacks: CommandCallback[] = []; + private sequenceCallbacks: SequenceCallback[] = []; + private statusCallbacks: StatusChangeCallback[] = []; + + // Playback control + private isPlaying = false; + private isPaused = false; + private currentSequenceIndex = 0; + private currentCommandIndex = 0; + private playbackIntervalId?: number; + + constructor(config: MockSequenceMasterConfig) { + this.config = config; + this.id = `mock-sequence-${Date.now()}`; + this.name = `Mock Sequence Master`; + + console.log(`Created MockSequenceMaster with ${config.sequences.length} sequences`); + } + + get status(): ConnectionStatus { + return this._status; + } + + async connect(): Promise { + console.log(`Connecting ${this.name}...`); + + this._status = { isConnected: true, lastConnected: new Date() }; + this.notifyStatusChange(); + + // Auto-start if configured + if (this.config.autoStart) { + await this.start(); + } + + console.log(`${this.name} connected`); + } + + async disconnect(): Promise { + console.log(`Disconnecting ${this.name}...`); + + await this.stop(); + this._status = { isConnected: false }; + this.notifyStatusChange(); + + console.log(`${this.name} disconnected`); + } + + async start(): Promise { + if (!this._status.isConnected) { + throw new Error("Cannot start: master not connected"); + } + + if (this.isPlaying && !this.isPaused) { + console.log("Sequence already playing"); + return; + } + + console.log(`Starting sequence playback...`); + this.isPlaying = true; + this.isPaused = false; + + // Reset to beginning if not paused + if (this.currentSequenceIndex >= this.config.sequences.length) { + this.currentSequenceIndex = 0; + this.currentCommandIndex = 0; + } + + this.startPlaybackLoop(); + } + + async stop(): Promise { + console.log("Stopping sequence playback"); + + this.isPlaying = false; + this.isPaused = false; + this.currentSequenceIndex = 0; + this.currentCommandIndex = 0; + + if (this.playbackIntervalId) { + clearInterval(this.playbackIntervalId); + this.playbackIntervalId = undefined; + } + } + + async pause(): Promise { + console.log("Pausing sequence playback"); + this.isPaused = true; + + if (this.playbackIntervalId) { + clearInterval(this.playbackIntervalId); + this.playbackIntervalId = undefined; + } + } + + async resume(): Promise { + if (!this.isPlaying || !this.isPaused) { + return; + } + + console.log("Resuming sequence playback"); + this.isPaused = false; + this.startPlaybackLoop(); + } + + + + // Event subscription methods + onCommand(callback: CommandCallback): UnsubscribeFn { + this.commandCallbacks.push(callback); + return () => { + const index = this.commandCallbacks.indexOf(callback); + if (index >= 0) { + this.commandCallbacks.splice(index, 1); + } + }; + } + + onSequence(callback: SequenceCallback): UnsubscribeFn { + this.sequenceCallbacks.push(callback); + return () => { + const index = this.sequenceCallbacks.indexOf(callback); + if (index >= 0) { + this.sequenceCallbacks.splice(index, 1); + } + }; + } + + onStatusChange(callback: StatusChangeCallback): UnsubscribeFn { + this.statusCallbacks.push(callback); + return () => { + const index = this.statusCallbacks.indexOf(callback); + if (index >= 0) { + this.statusCallbacks.splice(index, 1); + } + }; + } + + // Private methods + private startPlaybackLoop(): void { + if (this.playbackIntervalId) { + clearInterval(this.playbackIntervalId); + } + + this.playbackIntervalId = setInterval(() => { + this.executeNextCommand(); + }, getRobotPollingConfig().SEQUENCE_PLAYBACK_INTERVAL_MS); + } + + private executeNextCommand(): void { + if (!this.isPlaying || this.isPaused || this.config.sequences.length === 0) { + return; + } + + const currentSequence = this.config.sequences[this.currentSequenceIndex]; + if (!currentSequence) { + this.handleSequenceEnd(); + return; + } + + const currentCommand = currentSequence.commands[this.currentCommandIndex]; + if (!currentCommand) { + this.handleCommandEnd(); + return; + } + + console.log(`Executing command ${this.currentCommandIndex + 1}/${currentSequence.commands.length} from sequence "${currentSequence.name}"`); + + // Send the command + this.notifyCommand([currentCommand]); + + // Move to next command + this.currentCommandIndex++; + } + + private handleCommandEnd(): void { + const currentSequence = this.config.sequences[this.currentSequenceIndex]; + + // Sequence completed, notify + this.notifySequence(currentSequence); + + // Move to next sequence or loop + this.currentCommandIndex = 0; + this.currentSequenceIndex++; + + if (this.currentSequenceIndex >= this.config.sequences.length) { + if (this.config.loopMode) { + console.log("Looping back to first sequence"); + this.currentSequenceIndex = 0; + } else { + console.log("All sequences completed"); + this.stop(); + } + } + } + + private handleSequenceEnd(): void { + if (this.config.loopMode) { + this.currentSequenceIndex = 0; + this.currentCommandIndex = 0; + } else { + this.stop(); + } + } + + private notifyCommand(commands: RobotCommand[]): void { + this.commandCallbacks.forEach(callback => { + try { + callback(commands); + } catch (error) { + console.error("Error in command callback:", error); + } + }); + } + + private notifySequence(sequence: CommandSequence): void { + this.sequenceCallbacks.forEach(callback => { + try { + callback(sequence); + } catch (error) { + console.error("Error in sequence callback:", error); + } + }); + } + + private notifyStatusChange(): void { + this.statusCallbacks.forEach(callback => { + try { + callback(this._status); + } catch (error) { + console.error("Error in status callback:", error); + } + }); + } +} + +// Predefined demo sequences +export const DEMO_SEQUENCES: CommandSequence[] = [ + { + id: "gentle-wave", + name: "Gentle Wave Pattern", + totalDuration: 6000, + commands: [ + { + timestamp: 0, + joints: [ + { name: "Rotation", value: -10 }, + { name: "Pitch", value: 8 }, + { name: "Elbow", value: -12 } + ], + duration: 2000 + }, + { + timestamp: 2000, + joints: [ + { name: "Wrist_Roll", value: 10 } + ], + duration: 1000 + }, + { + timestamp: 3000, + joints: [ + { name: "Wrist_Roll", value: -10 } + ], + duration: 1000 + }, + { + timestamp: 4000, + joints: [ + { name: "Wrist_Roll", value: 0 }, + { name: "Rotation", value: 0 }, + { name: "Pitch", value: 0 }, + { name: "Elbow", value: 0 } + ], + duration: 2000 + } + ] + }, + { + id: "small-scan", + name: "Small Scanning Pattern", + totalDuration: 8000, + commands: [ + { + timestamp: 0, + joints: [ + { name: "Rotation", value: -15 }, + { name: "Pitch", value: 10 } + ], + duration: 2000 + }, + { + timestamp: 2000, + joints: [ + { name: "Rotation", value: 15 } + ], + duration: 3000 + }, + { + timestamp: 5000, + joints: [ + { name: "Rotation", value: 0 }, + { name: "Pitch", value: 0 } + ], + duration: 3000 + } + ] + }, + { + id: "tiny-flex", + name: "Tiny Flex Pattern", + totalDuration: 8000, + commands: [ + { + timestamp: 0, + joints: [ + { name: "Elbow", value: -15 }, + { name: "Wrist_Pitch", value: 8 } + ], + duration: 2000 + }, + { + timestamp: 2000, + joints: [ + { name: "Jaw", value: 8 } + ], + duration: 1000 + }, + { + timestamp: 3000, + joints: [ + { name: "Elbow", value: -25 } + ], + duration: 2000 + }, + { + timestamp: 5000, + joints: [ + { name: "Jaw", value: 0 }, + { name: "Elbow", value: 0 }, + { name: "Wrist_Pitch", value: 0 } + ], + duration: 3000 + } + ] + } +]; \ No newline at end of file diff --git a/src/lib/robot/drivers/MockSlave.ts b/src/lib/robot/drivers/MockSlave.ts new file mode 100644 index 0000000000000000000000000000000000000000..1262a964a95f01dd544c87c67237085fef6f087c --- /dev/null +++ b/src/lib/robot/drivers/MockSlave.ts @@ -0,0 +1,215 @@ +import type { + SlaveDriver, + DriverJointState, + ConnectionStatus, + RobotCommand, + MockSlaveConfig, + StateUpdateCallback, + StatusChangeCallback, + UnsubscribeFn +} from '$lib/types/robotDriver'; +import { getRobotPollingConfig } from '$lib/configs/performanceConfig'; + +/** + * Mock Slave Driver + * Simulates a physical robot for testing slave command execution + */ +export class MockSlave implements SlaveDriver { + readonly type = "slave" as const; + readonly id: string; + readonly name: string; + + private _status: ConnectionStatus = { isConnected: false }; + private config: MockSlaveConfig; + + // Joint states + private jointStates: DriverJointState[] = []; + + // Event callbacks + private stateCallbacks: StateUpdateCallback[] = []; + private statusCallbacks: StatusChangeCallback[] = []; + + // Simulation control + private simulationIntervalId?: number; + + constructor(config: MockSlaveConfig, initialJointStates: DriverJointState[]) { + this.config = config; + this.id = `mock-slave-${Date.now()}`; + this.name = `Mock Slave Robot`; + + // Initialize joint states + this.jointStates = initialJointStates.map(state => ({ + ...state, + virtualValue: state.virtualValue, + realValue: state.virtualValue // Mock starts with perfect sync + })); + + console.log(`Created MockSlave with ${this.jointStates.length} joints`); + } + + get status(): ConnectionStatus { + return this._status; + } + + async connect(): Promise { + console.log(`Connecting ${this.name}...`); + + // Simulate connection delay + if (this.config.simulateLatency) { + await new Promise(resolve => setTimeout(resolve, this.config.simulateLatency)); + } + + this._status = { isConnected: true, lastConnected: new Date() }; + this.notifyStatusChange(); + + // Start simulation loop + this.startSimulation(); + + console.log(`${this.name} connected`); + } + + async disconnect(): Promise { + console.log(`Disconnecting ${this.name}...`); + + this.stopSimulation(); + this._status = { isConnected: false }; + this.notifyStatusChange(); + + console.log(`${this.name} disconnected`); + } + + async executeCommand(command: RobotCommand): Promise { + if (!this._status.isConnected) { + throw new Error("Cannot execute command: slave not connected"); + } + + // Simulate command error if configured + if (this.config.simulateErrors && Math.random() < 0.1) { + throw new Error("Simulated command execution error"); + } + + console.log(`MockSlave executing command with ${command.joints.length} joint updates`); + + // Apply joint updates + for (const jointUpdate of command.joints) { + const joint = this.jointStates.find(j => j.name === jointUpdate.name); + if (joint) { + joint.virtualValue = jointUpdate.value; + // Mock robot has perfect response (real = virtual) + joint.realValue = jointUpdate.value; + } + } + + // Simulate response delay + if (this.config.responseDelay) { + await new Promise(resolve => setTimeout(resolve, this.config.responseDelay)); + } + + // Notify state update + this.notifyStateUpdate(); + } + + async executeCommands(commands: RobotCommand[]): Promise { + console.log(`MockSlave executing batch of ${commands.length} commands`); + + for (const command of commands) { + await this.executeCommand(command); + + // Small delay between commands + if (commands.length > 1) { + await new Promise(resolve => setTimeout(resolve, 50)); + } + } + } + + async readJointStates(): Promise { + if (!this._status.isConnected) { + throw new Error("Cannot read states: slave not connected"); + } + + // Simulate read latency + if (this.config.simulateLatency) { + await new Promise(resolve => setTimeout(resolve, this.config.simulateLatency)); + } + + return [...this.jointStates]; + } + + async writeJointState(jointName: string, value: number): Promise { + const command: RobotCommand = { + timestamp: Date.now(), + joints: [{ name: jointName, value }] + }; + + await this.executeCommand(command); + } + + async writeJointStates(updates: { jointName: string; value: number }[]): Promise { + const command: RobotCommand = { + timestamp: Date.now(), + joints: updates.map(update => ({ name: update.jointName, value: update.value })) + }; + + await this.executeCommand(command); + } + + // Event subscription methods + onStateUpdate(callback: StateUpdateCallback): UnsubscribeFn { + this.stateCallbacks.push(callback); + return () => { + const index = this.stateCallbacks.indexOf(callback); + if (index >= 0) { + this.stateCallbacks.splice(index, 1); + } + }; + } + + onStatusChange(callback: StatusChangeCallback): UnsubscribeFn { + this.statusCallbacks.push(callback); + return () => { + const index = this.statusCallbacks.indexOf(callback); + if (index >= 0) { + this.statusCallbacks.splice(index, 1); + } + }; + } + + // Private methods + private startSimulation(): void { + if (this.simulationIntervalId) { + clearInterval(this.simulationIntervalId); + } + + // Use performance config for update rate + this.simulationIntervalId = setInterval(() => { + this.notifyStateUpdate(); + }, getRobotPollingConfig().STATE_UPDATE_INTERVAL_MS); + } + + private stopSimulation(): void { + if (this.simulationIntervalId) { + clearInterval(this.simulationIntervalId); + this.simulationIntervalId = undefined; + } + } + + private notifyStateUpdate(): void { + this.stateCallbacks.forEach(callback => { + try { + callback([...this.jointStates]); + } catch (error) { + console.error("Error in state update callback:", error); + } + }); + } + + private notifyStatusChange(): void { + this.statusCallbacks.forEach(callback => { + try { + callback(this._status); + } catch (error) { + console.error("Error in status change callback:", error); + } + }); + } +} \ No newline at end of file diff --git a/src/lib/robot/drivers/RemoteServerMaster.ts b/src/lib/robot/drivers/RemoteServerMaster.ts new file mode 100644 index 0000000000000000000000000000000000000000..acd9f0c4ddbef4dc8d1d9a9181d881726384be60 --- /dev/null +++ b/src/lib/robot/drivers/RemoteServerMaster.ts @@ -0,0 +1,407 @@ +import type { + MasterDriver, + ConnectionStatus, + RobotCommand, + CommandSequence, + RemoteServerMasterConfig, + CommandCallback, + SequenceCallback, + StatusChangeCallback, + UnsubscribeFn +} from '$lib/types/robotDriver'; +import { getWebSocketConfig } from '$lib/configs/performanceConfig'; + +/** + * Remote Server Master Driver + * Connects to FastAPI WebSocket server for remote robot control + */ +export class RemoteServerMaster implements MasterDriver { + readonly type = "master" as const; + readonly id: string; + readonly name: string; + + private _status: ConnectionStatus = { isConnected: false }; + private config: RemoteServerMasterConfig; + private robotId: string; + + // WebSocket connection + private ws?: WebSocket; + private reconnectAttempts = 0; + private maxReconnectAttempts = getWebSocketConfig().MAX_RECONNECT_ATTEMPTS; + private reconnectDelay = getWebSocketConfig().INITIAL_RECONNECT_DELAY_MS; + private heartbeatInterval?: number; + + // Event callbacks + private commandCallbacks: CommandCallback[] = []; + private sequenceCallbacks: SequenceCallback[] = []; + private statusCallbacks: StatusChangeCallback[] = []; + + constructor(config: RemoteServerMasterConfig, robotId: string) { + this.config = config; + this.robotId = robotId; + this.id = `remote-master-${robotId}-${Date.now()}`; + this.name = `Remote Server Master (${robotId})`; + + console.log(`Created RemoteServerMaster for robot ${robotId}`); + } + + get status(): ConnectionStatus { + return this._status; + } + + async connect(): Promise { + console.log(`Connecting ${this.name} to ${this.config.url}...`); + + try { + // Build WebSocket URL + const wsUrl = this.buildWebSocketUrl(); + + // Create WebSocket connection + this.ws = new WebSocket(wsUrl); + + // Set up event handlers + this.setupWebSocketHandlers(); + + // Wait for connection + await this.waitForConnection(); + + // Start heartbeat + this.startHeartbeat(); + + this._status = { + isConnected: true, + lastConnected: new Date(), + error: undefined + }; + this.notifyStatusChange(); + + console.log(`${this.name} connected successfully`); + + } catch (error) { + this._status = { + isConnected: false, + error: `Connection failed: ${error}` + }; + this.notifyStatusChange(); + throw error; + } + } + + async disconnect(): Promise { + console.log(`Disconnecting ${this.name}...`); + + this.stopHeartbeat(); + + if (this.ws) { + this.ws.close(); + this.ws = undefined; + } + + this._status = { isConnected: false }; + this.notifyStatusChange(); + + console.log(`${this.name} disconnected`); + } + + async start(): Promise { + if (!this._status.isConnected) { + throw new Error("Cannot start: master not connected"); + } + + console.log(`Starting remote control for robot ${this.robotId}`); + + // Send start command to server + await this.sendMessage({ + type: "start_control", + timestamp: new Date().toISOString(), + data: { robotId: this.robotId } + }); + } + + async stop(): Promise { + console.log(`Stopping remote control for robot ${this.robotId}`); + + if (this._status.isConnected && this.ws) { + await this.sendMessage({ + type: "stop_control", + timestamp: new Date().toISOString(), + data: { robotId: this.robotId } + }); + } + } + + async pause(): Promise { + console.log(`Pausing remote control for robot ${this.robotId}`); + + if (this._status.isConnected && this.ws) { + await this.sendMessage({ + type: "pause_control", + timestamp: new Date().toISOString(), + data: { robotId: this.robotId } + }); + } + } + + async resume(): Promise { + console.log(`Resuming remote control for robot ${this.robotId}`); + + if (this._status.isConnected && this.ws) { + await this.sendMessage({ + type: "resume_control", + timestamp: new Date().toISOString(), + data: { robotId: this.robotId } + }); + } + } + + + // Event subscription methods + onCommand(callback: CommandCallback): UnsubscribeFn { + this.commandCallbacks.push(callback); + return () => { + const index = this.commandCallbacks.indexOf(callback); + if (index >= 0) { + this.commandCallbacks.splice(index, 1); + } + }; + } + + onSequence(callback: SequenceCallback): UnsubscribeFn { + this.sequenceCallbacks.push(callback); + return () => { + const index = this.sequenceCallbacks.indexOf(callback); + if (index >= 0) { + this.sequenceCallbacks.splice(index, 1); + } + }; + } + + onStatusChange(callback: StatusChangeCallback): UnsubscribeFn { + this.statusCallbacks.push(callback); + return () => { + const index = this.statusCallbacks.indexOf(callback); + if (index >= 0) { + this.statusCallbacks.splice(index, 1); + } + }; + } + + // Private methods + private buildWebSocketUrl(): string { + const baseUrl = this.config.url.replace(/^http/, 'ws'); + return `${baseUrl}/ws/master/${this.robotId}`; + } + + private setupWebSocketHandlers(): void { + if (!this.ws) return; + + this.ws.onopen = () => { + console.log(`WebSocket connected for robot ${this.robotId}`); + this.reconnectAttempts = 0; // Reset on successful connection + }; + + this.ws.onmessage = (event) => { + try { + const message = JSON.parse(event.data); + this.handleServerMessage(message); + } catch (error) { + console.error("Failed to parse WebSocket message:", error); + } + }; + + this.ws.onclose = (event) => { + console.log(`WebSocket closed for robot ${this.robotId}:`, event.code, event.reason); + this.handleDisconnection(); + }; + + this.ws.onerror = (error) => { + console.error(`WebSocket error for robot ${this.robotId}:`, error); + this._status = { + isConnected: false, + error: `WebSocket error: ${error}` + }; + this.notifyStatusChange(); + }; + } + + private async waitForConnection(): Promise { + if (!this.ws) throw new Error("WebSocket not created"); + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Connection timeout")); + }, getWebSocketConfig().CONNECTION_TIMEOUT_MS); + + if (this.ws!.readyState === WebSocket.OPEN) { + clearTimeout(timeout); + resolve(); + return; + } + + this.ws!.onopen = () => { + clearTimeout(timeout); + resolve(); + }; + + this.ws!.onerror = (error) => { + clearTimeout(timeout); + reject(error); + }; + }); + } + + private async sendMessage(message: Record): Promise { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + throw new Error("WebSocket not connected"); + } + + this.ws.send(JSON.stringify(message)); + } + + private handleServerMessage(message: Record): void { + const { type, data } = message; + + console.log(`๐Ÿ” RemoteServerMaster received: ${type}`, data); + + switch (type) { + case 'command': + if (data) { + this.notifyCommand([data as RobotCommand]); + } + break; + + case 'sequence': + if (data) { + this.notifySequence(data as CommandSequence); + } + break; + + case 'play_sequence': + if (data) { + console.log(`Playing sequence from server on robot ${this.robotId}`); + this.notifySequence(data as CommandSequence); + } + break; + + case 'robot_state': + console.log(`Received robot state for ${this.robotId}:`, data); + break; + + case 'slave_status': + console.log(`Slave status update for ${this.robotId}:`, data); + // Status updates don't need to trigger robot movement + break; + + case 'joint_states': + console.log(`Joint states update for ${this.robotId}:`, data); + // Convert joint states from slave into robot commands to update local robot + if (data && typeof data === 'object' && 'joints' in data) { + const jointsData = data.joints as Array<{name: string, virtual_value: number, real_value?: number}>; + if (Array.isArray(jointsData) && jointsData.length > 0) { + const command: RobotCommand = { + timestamp: Date.now(), + joints: jointsData.map(joint => ({ + name: joint.name, + value: joint.real_value !== undefined ? joint.real_value : joint.virtual_value + })), + metadata: { source: "remote_slave_joint_states" } + }; + + console.log(`๐Ÿ”„ Converting joint states to command:`, command); + this.notifyCommand([command]); + } + } + break; + + case 'slave_error': + console.error(`Slave error for ${this.robotId}:`, data); + break; + + case 'heartbeat_ack': + // Heartbeat acknowledged, connection is alive + break; + + default: + console.warn(`Unknown message type from server: ${type}`); + } + } + + private handleDisconnection(): void { + this._status = { isConnected: false }; + this.notifyStatusChange(); + this.stopHeartbeat(); + + // Attempt reconnection if not manually disconnected + if (this.reconnectAttempts < this.maxReconnectAttempts) { + this.attemptReconnection(); + } + } + + private async attemptReconnection(): Promise { + this.reconnectAttempts++; + const maxDelay = getWebSocketConfig().MAX_RECONNECT_DELAY_MS; + const delay = Math.min(this.reconnectDelay * Math.pow(1.5, this.reconnectAttempts - 1), maxDelay); + + console.log(`Attempting master reconnection ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms...`); + + setTimeout(async () => { + try { + await this.connect(); + } catch (error) { + console.error(`Master reconnection attempt ${this.reconnectAttempts} failed:`, error); + } + }, delay); + } + + private startHeartbeat(): void { + this.heartbeatInterval = setInterval(async () => { + if (this._status.isConnected && this.ws) { + try { + await this.sendMessage({ + type: "heartbeat", + timestamp: new Date().toISOString() + }); + } catch (error) { + console.error("Failed to send heartbeat:", error); + } + } + }, getWebSocketConfig().HEARTBEAT_INTERVAL_MS); + } + + private stopHeartbeat(): void { + if (this.heartbeatInterval) { + clearInterval(this.heartbeatInterval); + this.heartbeatInterval = undefined; + } + } + + private notifyCommand(commands: RobotCommand[]): void { + this.commandCallbacks.forEach(callback => { + try { + callback(commands); + } catch (error) { + console.error("Error in command callback:", error); + } + }); + } + + private notifySequence(sequence: CommandSequence): void { + this.sequenceCallbacks.forEach(callback => { + try { + callback(sequence); + } catch (error) { + console.error("Error in sequence callback:", error); + } + }); + } + + private notifyStatusChange(): void { + this.statusCallbacks.forEach(callback => { + try { + callback(this._status); + } catch (error) { + console.error("Error in status callback:", error); + } + }); + } +} \ No newline at end of file diff --git a/src/lib/robot/drivers/RemoteServerSlave.ts b/src/lib/robot/drivers/RemoteServerSlave.ts new file mode 100644 index 0000000000000000000000000000000000000000..18070fec93d6ce28c76edb96293c9787065c050e --- /dev/null +++ b/src/lib/robot/drivers/RemoteServerSlave.ts @@ -0,0 +1,441 @@ +import type { + SlaveDriver, + DriverJointState, + ConnectionStatus, + RobotCommand, + RemoteServerSlaveConfig, + StateUpdateCallback, + StatusChangeCallback, + UnsubscribeFn +} from '$lib/types/robotDriver'; +import { getWebSocketConfig, getCommunicationConfig } from '$lib/configs/performanceConfig'; + +/** + * Remote Server Slave Driver + * Connects to FastAPI WebSocket server as a slave to receive and execute commands + */ +export class RemoteServerSlave implements SlaveDriver { + readonly type = "slave" as const; + readonly id: string; + readonly name: string; + + private _status: ConnectionStatus = { isConnected: false }; + private config: RemoteServerSlaveConfig; + + // Joint states + private jointStates: DriverJointState[] = []; + + // WebSocket connection + private ws?: WebSocket; + private reconnectAttempts = 0; + private maxReconnectAttempts = getWebSocketConfig().MAX_RECONNECT_ATTEMPTS; + private reconnectDelay = getWebSocketConfig().INITIAL_RECONNECT_DELAY_MS; + private heartbeatInterval?: number; + + // Event callbacks + private stateCallbacks: StateUpdateCallback[] = []; + private statusCallbacks: StatusChangeCallback[] = []; + + constructor(config: RemoteServerSlaveConfig, initialJointStates: DriverJointState[]) { + this.config = config; + this.id = `remote-slave-${config.robotId}-${Date.now()}`; + this.name = `Remote Server Slave (${config.robotId})`; + + // Initialize joint states + this.jointStates = initialJointStates.map(state => ({ ...state })); + + console.log(`Created RemoteServerSlave for robot ${config.robotId} with ${this.jointStates.length} joints`); + } + + get status(): ConnectionStatus { + return this._status; + } + + async connect(): Promise { + console.log(`Connecting ${this.name} to ${this.config.url}...`); + + try { + // Build WebSocket URL + const wsUrl = this.buildWebSocketUrl(); + + // Create WebSocket connection + this.ws = new WebSocket(wsUrl); + + // Set up event handlers + this.setupWebSocketHandlers(); + + // Wait for connection + await this.waitForConnection(); + + this._status = { + isConnected: true, + lastConnected: new Date(), + error: undefined + }; + this.notifyStatusChange(); + + console.log(`${this.name} connected successfully`); + + // Send initial status + await this.sendStatusUpdate(); + + } catch (error) { + this._status = { + isConnected: false, + error: `Connection failed: ${error}` + }; + this.notifyStatusChange(); + throw error; + } + } + + async disconnect(): Promise { + console.log(`Disconnecting ${this.name}...`); + + this.stopHeartbeat(); + + if (this.ws) { + this.ws.close(); + this.ws = undefined; + } + + this._status = { isConnected: false }; + this.notifyStatusChange(); + + console.log(`${this.name} disconnected`); + } + + async executeCommand(command: RobotCommand): Promise { + if (!this._status.isConnected) { + throw new Error("Cannot execute command: slave not connected"); + } + + console.log(`RemoteServerSlave executing command with ${command.joints.length} joint updates`); + + try { + // Update joint states (simulate execution) + for (const jointUpdate of command.joints) { + const joint = this.jointStates.find(j => j.name === jointUpdate.name); + if (joint) { + joint.virtualValue = jointUpdate.value; + joint.realValue = jointUpdate.value; // For remote slaves, assume perfect execution + } + } + + // Notify state update + this.notifyStateUpdate(); + + // Send joint states to server (so master can receive updates) + await this.sendJointStates(); + + // Send status update to server + await this.sendStatusUpdate(); + + } catch (error) { + console.error("Error executing command:", error); + await this.sendError(`Command execution failed: ${error}`); + throw error; + } + } + + async executeCommands(commands: RobotCommand[]): Promise { + console.log(`RemoteServerSlave executing batch of ${commands.length} commands`); + + for (const command of commands) { + await this.executeCommand(command); + + // Use optimized delay between commands + if (commands.length > 1) { + await new Promise(resolve => setTimeout(resolve, getCommunicationConfig().BATCH_COMMAND_DELAY_MS)); + } + } + } + + async readJointStates(): Promise { + return [...this.jointStates]; + } + + async writeJointState(jointName: string, value: number): Promise { + const command: RobotCommand = { + timestamp: Date.now(), + joints: [{ name: jointName, value }] + }; + + await this.executeCommand(command); + } + + async writeJointStates(updates: { jointName: string; value: number }[]): Promise { + const command: RobotCommand = { + timestamp: Date.now(), + joints: updates.map(update => ({ name: update.jointName, value: update.value })) + }; + + await this.executeCommand(command); + } + + // Event subscription methods + onStateUpdate(callback: StateUpdateCallback): UnsubscribeFn { + this.stateCallbacks.push(callback); + return () => { + const index = this.stateCallbacks.indexOf(callback); + if (index >= 0) { + this.stateCallbacks.splice(index, 1); + } + }; + } + + onStatusChange(callback: StatusChangeCallback): UnsubscribeFn { + this.statusCallbacks.push(callback); + return () => { + const index = this.statusCallbacks.indexOf(callback); + if (index >= 0) { + this.statusCallbacks.splice(index, 1); + } + }; + } + + // Private methods + private buildWebSocketUrl(): string { + const baseUrl = this.config.url.replace(/^http/, 'ws'); + return `${baseUrl}/ws/slave/${this.config.robotId}`; + } + + private setupWebSocketHandlers(): void { + if (!this.ws) return; + + this.ws.onopen = () => { + console.log(`WebSocket connected for slave ${this.config.robotId}`); + this.reconnectAttempts = 0; // Reset on successful connection + this.startHeartbeat(); + }; + + this.ws.onmessage = (event) => { + try { + const message = JSON.parse(event.data); + this.handleServerMessage(message); + } catch (error) { + console.error("Failed to parse WebSocket message:", error); + } + }; + + this.ws.onclose = (event) => { + console.log(`WebSocket closed for slave ${this.config.robotId}:`, event.code, event.reason); + this.handleDisconnection(); + }; + + this.ws.onerror = (error) => { + console.error(`WebSocket error for slave ${this.config.robotId}:`, error); + this._status = { + isConnected: false, + error: `WebSocket error: ${error}` + }; + this.notifyStatusChange(); + }; + } + + private async waitForConnection(): Promise { + if (!this.ws) throw new Error("WebSocket not created"); + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Connection timeout")); + }, getWebSocketConfig().CONNECTION_TIMEOUT_MS); + + if (this.ws!.readyState === WebSocket.OPEN) { + clearTimeout(timeout); + resolve(); + return; + } + + this.ws!.onopen = () => { + clearTimeout(timeout); + resolve(); + }; + + this.ws!.onerror = (error) => { + clearTimeout(timeout); + reject(error); + }; + }); + } + + private async sendMessage(message: Record): Promise { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + throw new Error("WebSocket not connected"); + } + + this.ws.send(JSON.stringify(message)); + } + + private handleServerMessage(message: Record): void { + const { type, data } = message; + + switch (type) { + case 'execute_command': + if (data) { + this.executeCommand(data as RobotCommand).catch(error => { + console.error("Failed to execute command from server:", error); + }); + } + break; + + case 'execute_sequence': + if (data && typeof data === 'object' && 'commands' in data) { + const sequence = data as { commands: RobotCommand[] }; + this.executeCommands(sequence.commands).catch(error => { + console.error("Failed to execute sequence from server:", error); + }); + } + break; + + case 'play_sequence': + if (data && typeof data === 'object' && 'commands' in data) { + console.log(`Playing sequence from server on remote slave ${this.config.robotId}`); + const sequence = data as { commands: RobotCommand[] }; + this.executeCommands(sequence.commands).catch(error => { + console.error("Failed to play sequence from server:", error); + }); + } + break; + + case 'stop_sequence': + console.log(`Stopping sequences on remote slave ${this.config.robotId}`); + // For a simple slave, we don't track running sequences, so just log + break; + + case 'status_request': + this.sendStatusUpdate().catch(error => { + console.error("Failed to send status update:", error); + }); + break; + + default: + console.warn(`Unknown message type from server: ${type}`); + } + } + + private handleDisconnection(): void { + this._status = { isConnected: false }; + this.notifyStatusChange(); + this.stopHeartbeat(); + + // Attempt reconnection if not manually disconnected + if (this.reconnectAttempts < this.maxReconnectAttempts) { + this.attemptReconnection(); + } + } + + private async attemptReconnection(): Promise { + this.reconnectAttempts++; + const maxDelay = getWebSocketConfig().MAX_RECONNECT_DELAY_MS; + const delay = Math.min(this.reconnectDelay * Math.pow(1.5, this.reconnectAttempts - 1), maxDelay); + + console.log(`Attempting slave reconnection ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms...`); + + setTimeout(async () => { + try { + await this.connect(); + } catch (error) { + console.error(`Slave reconnection attempt ${this.reconnectAttempts} failed:`, error); + } + }, delay); + } + + private startHeartbeat(): void { + this.heartbeatInterval = setInterval(async () => { + if (this._status.isConnected && this.ws) { + try { + await this.sendStatusUpdate(); + } catch (error) { + console.error("Failed to send heartbeat status:", error); + } + } + }, getWebSocketConfig().HEARTBEAT_INTERVAL_MS); + } + + private stopHeartbeat(): void { + if (this.heartbeatInterval) { + clearInterval(this.heartbeatInterval); + this.heartbeatInterval = undefined; + } + } + + private async sendStatusUpdate(): Promise { + if (!this._status.isConnected) return; + + try { + await this.sendMessage({ + type: "status_update", + timestamp: new Date().toISOString(), + data: { + slave_id: this.id, + robot_id: this.config.robotId, + is_connected: this._status.isConnected, + joint_count: this.jointStates.length + } + }); + } catch (error) { + console.error("Failed to send status update:", error); + } + } + + private async sendJointStates(): Promise { + if (!this._status.isConnected) return; + + try { + await this.sendMessage({ + type: "joint_states", + timestamp: new Date().toISOString(), + data: { + slave_id: this.id, + robot_id: this.config.robotId, + joints: this.jointStates.map(joint => ({ + name: joint.name, + virtual_value: joint.virtualValue, + real_value: joint.realValue + })) + } + }); + } catch (error) { + console.error("Failed to send joint states:", error); + } + } + + private async sendError(errorMessage: string): Promise { + if (!this._status.isConnected) return; + + try { + await this.sendMessage({ + type: "error", + timestamp: new Date().toISOString(), + data: { + slave_id: this.id, + robot_id: this.config.robotId, + error: errorMessage + } + }); + } catch (error) { + console.error("Failed to send error message:", error); + } + } + + private notifyStateUpdate(): void { + this.stateCallbacks.forEach(callback => { + try { + callback([...this.jointStates]); + } catch (error) { + console.error("Error in state update callback:", error); + } + }); + } + + private notifyStatusChange(): void { + this.statusCallbacks.forEach(callback => { + try { + callback(this._status); + } catch (error) { + console.error("Error in status change callback:", error); + } + }); + } +} \ No newline at end of file diff --git a/src/lib/robot/drivers/USBMaster.ts b/src/lib/robot/drivers/USBMaster.ts new file mode 100644 index 0000000000000000000000000000000000000000..a88945df3409a4ea4b23e41d7c75e03af949579b --- /dev/null +++ b/src/lib/robot/drivers/USBMaster.ts @@ -0,0 +1,381 @@ +import type { + MasterDriver, + ConnectionStatus, + RobotCommand, + CommandSequence, + USBMasterConfig, + CommandCallback, + SequenceCallback, + StatusChangeCallback, + UnsubscribeFn +} from '$lib/types/robotDriver'; +import { getRobotPollingConfig, getDataProcessingConfig } from '$lib/configs/performanceConfig'; + +/** + * USB Master Driver + * Reads positions from a physical USB robot and forwards movements to slaves + * Allows physical robot to act as a master controller for digital twins and other robots + */ +export class USBMaster implements MasterDriver { + readonly type = "master" as const; + readonly id: string; + readonly name: string; + + private _status: ConnectionStatus = { isConnected: false }; + private config: USBMasterConfig; + + // Robot joint configuration + private jointIds: number[] = [1, 2, 3, 4, 5, 6]; // Default servo IDs for so-arm100 + private jointNames: string[] = ["Rotation", "Pitch", "Elbow", "Wrist_Pitch", "Wrist_Roll", "Jaw"]; + private lastPositions: Map = new Map(); + private restPositions: Map = new Map(); // Store initial rest positions + private positionHistory: Map = new Map(); + + // Polling control + private pollingInterval?: number; + private isPolling = false; + private isPaused = false; + private isReading = false; // Prevent concurrent reads + + // Event callbacks + private commandCallbacks: CommandCallback[] = []; + private sequenceCallbacks: SequenceCallback[] = []; + private statusCallbacks: StatusChangeCallback[] = []; + + // Import the unlock SDK dynamically + private sdk: typeof import('$lib/feetech.js/scsServoSDKUnlock.mjs') | null = null; + + constructor(config: USBMasterConfig) { + this.config = config; + this.id = `usb-master-${Date.now()}`; + this.name = `USB Master Robot`; + + // Initialize position tracking + this.jointIds.forEach((id) => { + this.lastPositions.set(id, 2048); // Center position (will be updated on connect) + this.restPositions.set(id, 2048); // Rest position baseline (will be updated on connect) + this.positionHistory.set(id, []); + }); + + console.log(`๐Ÿ”Œ Created USBMaster with ${this.jointIds.length} joints (unlock mode)`); + } + + get status(): ConnectionStatus { + return this._status; + } + + async connect(): Promise { + console.log(`๐Ÿ”Œ Connecting ${this.name} to USB robot...`); + + try { + // Dynamically import the unlock SDK + this.sdk = await import('$lib/feetech.js/scsServoSDKUnlock.mjs'); + + // Connect to USB robot + await this.sdk.connect({ + baudRate: this.config.baudRate || 1000000, + protocolEnd: 0 + }); + + // Read initial positions + await this.readInitialPositions(); + + this._status = { + isConnected: true, + lastConnected: new Date(), + error: undefined + }; + this.notifyStatusChange(); + + console.log(`โœ… ${this.name} connected successfully`); + + } catch (error) { + this._status = { + isConnected: false, + error: `Connection failed: ${error}` + }; + this.notifyStatusChange(); + throw error; + } + } + + async disconnect(): Promise { + console.log(`๐Ÿ”Œ Disconnecting ${this.name}...`); + + this.stopPolling(); + + if (this.sdk && this._status.isConnected) { + try { + await this.sdk.disconnect(); + } catch (error) { + console.warn("Error disconnecting USB robot:", error); + } + } + + this._status = { isConnected: false }; + this.notifyStatusChange(); + + console.log(`โœ… ${this.name} disconnected`); + } + + async start(): Promise { + if (!this._status.isConnected) { + throw new Error("Cannot start: USB master not connected"); + } + + console.log(`๐ŸŽฎ Starting USB master polling...`); + this.startPolling(); + } + + async stop(): Promise { + console.log(`โน๏ธ Stopping USB master polling...`); + this.stopPolling(); + } + + async pause(): Promise { + console.log(`โธ๏ธ Pausing USB master polling...`); + this.isPaused = true; + } + + async resume(): Promise { + console.log(`โ–ถ๏ธ Resuming USB master polling...`); + this.isPaused = false; + } + + // Event subscription methods + onCommand(callback: CommandCallback): UnsubscribeFn { + this.commandCallbacks.push(callback); + return () => { + const index = this.commandCallbacks.indexOf(callback); + if (index >= 0) { + this.commandCallbacks.splice(index, 1); + } + }; + } + + onSequence(callback: SequenceCallback): UnsubscribeFn { + this.sequenceCallbacks.push(callback); + return () => { + const index = this.sequenceCallbacks.indexOf(callback); + if (index >= 0) { + this.sequenceCallbacks.splice(index, 1); + } + }; + } + + onStatusChange(callback: StatusChangeCallback): UnsubscribeFn { + this.statusCallbacks.push(callback); + return () => { + const index = this.statusCallbacks.indexOf(callback); + if (index >= 0) { + this.statusCallbacks.splice(index, 1); + } + }; + } + + // Private methods + private async readInitialPositions(): Promise { + if (!this.sdk) return; + + try { + console.log("๐Ÿ“– Reading initial REST positions from USB robot (individual reads)..."); + + let successCount = 0; + for (const servoId of this.jointIds) { + try { + const position = await this.sdk.readPosition(servoId); + + // Store as both last position and rest position baseline + this.lastPositions.set(servoId, position); + this.restPositions.set(servoId, position); + + console.log(`Joint ${servoId}: REST position = ${position} (servo units)`); + successCount++; + } catch (err) { + console.warn(`Failed to read servo ${servoId}:`, err); + // Keep default center positions for failed reads + } + } + + console.log(`๐Ÿ“– Successfully read ${successCount}/${this.jointIds.length} REST positions`); + console.log("๐Ÿ  Robot is now calibrated to REST position baseline"); + + } catch (error) { + console.warn("Could not read initial positions:", error); + console.log("๐Ÿ  Using default center positions as REST baseline"); + } + } + + private startPolling(): void { + if (this.isPolling) return; + + this.isPolling = true; + // Use performance config instead of hardcoded value + const pollInterval = this.config.pollInterval || getRobotPollingConfig().USB_MASTER_POLL_INTERVAL_MS; + + this.pollingInterval = setInterval(async () => { + if (this.isPaused || !this._status.isConnected || this.isReading) return; + + try { + await this.pollRobotPositions(); + } catch (error) { + console.error("Error polling robot positions:", error); + + // Handle connection errors + if (error instanceof Error && error.message?.includes('Not connected')) { + this._status = { + isConnected: false, + error: `Connection lost: ${error.message}` + }; + this.notifyStatusChange(); + this.stopPolling(); + } + } + }, pollInterval); + + console.log(`๐Ÿ”„ USB master polling started (${pollInterval}ms interval)`); + } + + private stopPolling(): void { + if (this.pollingInterval) { + clearInterval(this.pollingInterval); + this.pollingInterval = undefined; + } + this.isPolling = false; + } + + private async pollRobotPositions(): Promise { + if (!this.sdk || this.isReading) return; + + this.isReading = true; // Prevent concurrent reads + + try { + const currentPositions: Map = new Map(); + const changedJoints: { name: string; value: number }[] = []; + let validReads = 0; + + // Read all servo positions + for (const servoId of this.jointIds) { + try { + const position = await this.sdk.readPosition(servoId); + if (position !== null && position !== undefined) { + currentPositions.set(servoId, position); + validReads++; + } + } catch { + // Skip individual servo read errors + } + } + + currentPositions.forEach((position: number, servoId: number) => { + // Process all readings - position 0 is valid + const lastPosition = this.lastPositions.get(servoId) || 2048; + // Use performance config for threshold + const threshold = getDataProcessingConfig().ENABLE_SMOOTHING + ? getRobotPollingConfig().POSITION_CHANGE_THRESHOLD + : 1; // Maximum sensitivity when smoothing disabled + + if (Math.abs(position - lastPosition) > threshold) { + const jointIndex = this.jointIds.indexOf(servoId); + if (jointIndex >= 0) { + const jointName = this.jointNames[jointIndex]; + const degrees = this.servoToRelativeDegrees(position, servoId); + + console.log(`๐ŸŽฏ Joint ${jointName} moved: ${lastPosition} โ†’ ${position} (${degrees.toFixed(1)}ยฐ)`); + + // Apply smoothing if enabled in performance config + if (this.config.smoothing && getDataProcessingConfig().ENABLE_SMOOTHING) { + const history = this.positionHistory.get(servoId) || []; + history.push(degrees); + const maxHistory = getDataProcessingConfig().SMOOTHING_HISTORY_SIZE; + if (history.length > maxHistory) history.shift(); + this.positionHistory.set(servoId, history); + + // Use average of recent positions + const smoothedDegrees = history.reduce((a, b) => a + b, 0) / history.length; + changedJoints.push({ name: jointName, value: smoothedDegrees }); + } else { + changedJoints.push({ name: jointName, value: degrees }); + } + + this.lastPositions.set(servoId, position); + } + } + }); + + // Log polling status occasionally (less frequent with faster polling) + if (Date.now() % 5000 < getRobotPollingConfig().USB_MASTER_POLL_INTERVAL_MS) { + console.log(`๐Ÿ”„ USB polling: ${validReads}/${this.jointIds.length} joints read successfully`); + } + + // If there are changes, emit command + if (changedJoints.length > 0) { + const command: RobotCommand = { + timestamp: Date.now(), + joints: changedJoints, + metadata: { source: "usb_master_polling" } + }; + + console.log(`๐Ÿ“ค Emitting command with ${changedJoints.length} joint changes`); + this.notifyCommand([command]); + } + + } catch (error) { + console.error("Polling error:", error); + + // Don't re-throw port access errors, just log them + if (error instanceof Error && error.message?.includes('Releasing Default reader')) { + console.warn("Serial port access conflict, skipping this poll cycle"); + return; + } + + throw error; // Re-throw other errors + } finally { + // Always release the reading lock + this.isReading = false; + } + } + + private servoToRelativeDegrees(servoPosition: number, servoId: number): number { + // Get the rest position for this servo + const restPosition = this.restPositions.get(servoId) || 2048; + + // Calculate relative movement from rest position + const deltaServoUnits = servoPosition - restPosition; + + // Convert servo units to degrees (assuming full range 0-4095 = 360ยฐ) + const relativeDegrees = (deltaServoUnits / 4095) * 360; + + return relativeDegrees; + } + + private notifyCommand(commands: RobotCommand[]): void { + this.commandCallbacks.forEach(callback => { + try { + callback(commands); + } catch (error) { + console.error("Error in command callback:", error); + } + }); + } + + private notifySequence(sequence: CommandSequence): void { + this.sequenceCallbacks.forEach(callback => { + try { + callback(sequence); + } catch (error) { + console.error("Error in sequence callback:", error); + } + }); + } + + private notifyStatusChange(): void { + this.statusCallbacks.forEach(callback => { + try { + callback(this._status); + } catch (error) { + console.error("Error in status callback:", error); + } + }); + } +} \ No newline at end of file diff --git a/src/lib/robot/drivers/USBSlave.ts b/src/lib/robot/drivers/USBSlave.ts new file mode 100644 index 0000000000000000000000000000000000000000..59b7140e9ba589a9b5ef899f82dadf496eaaf7c3 --- /dev/null +++ b/src/lib/robot/drivers/USBSlave.ts @@ -0,0 +1,325 @@ +import type { + SlaveDriver, + DriverJointState, + ConnectionStatus, + RobotCommand, + USBSlaveConfig, + StateUpdateCallback, + StatusChangeCallback, + UnsubscribeFn +} from '$lib/types/robotDriver'; +import { scsServoSDK } from '$lib/feetech.js'; +import { servoPositionToAngle, degreesToServoPosition } from '$lib/utils'; + +/** + * USB Slave Driver + * Controls physical robots via feetech.js USB connection + */ +export class USBSlave implements SlaveDriver { + readonly type = "slave" as const; + readonly id: string; + readonly name: string; + + private _status: ConnectionStatus = { isConnected: false }; + private config: USBSlaveConfig; + + // Joint states + private jointStates: DriverJointState[] = []; + private initialPositions: number[] = []; + + // Event callbacks + private stateCallbacks: StateUpdateCallback[] = []; + private statusCallbacks: StatusChangeCallback[] = []; + + constructor(config: USBSlaveConfig, initialJointStates: DriverJointState[]) { + this.config = config; + this.id = `usb-slave-${Date.now()}`; + this.name = `USB Slave Robot`; + + // Initialize joint states + this.jointStates = initialJointStates.map(state => ({ ...state })); + + // Validate that all active joints have servo IDs + const missingServos = this.jointStates.filter(j => !j.servoId); + if (missingServos.length > 0) { + console.warn( + `USB slave: Some joints missing servo IDs: ${missingServos.map(j => j.name).join(', ')}` + ); + } + + console.log(`Created USBSlave with ${this.jointStates.length} joints`); + } + + get status(): ConnectionStatus { + return this._status; + } + + async connect(): Promise { + console.log(`Connecting ${this.name}...`); + + try { + this._status = { isConnected: false }; + this.notifyStatusChange(); + + await scsServoSDK.connect(); + + // Initialize each servo + const initialPositions: number[] = []; + + for (const joint of this.jointStates) { + if (!joint.servoId) { + initialPositions.push(0); + continue; + } + + try { + if (joint.type === "continuous") { + // Configure for speed control + await scsServoSDK.setWheelMode(joint.servoId); + await scsServoSDK.writeWheelSpeed(joint.servoId, 0); + initialPositions.push(0); + + // Update joint state + joint.realValue = 0; + } else { + // Configure for position control + await scsServoSDK.setPositionMode(joint.servoId); + + // Read initial position + const servoPosition = await scsServoSDK.readPosition(joint.servoId); + const positionInDegrees = servoPositionToAngle(servoPosition); + initialPositions.push(positionInDegrees); + + // Enable torque + await scsServoSDK.writeTorqueEnable(joint.servoId, true); + + // Update joint state + joint.realValue = positionInDegrees; + } + } catch (error) { + console.error(`Failed to initialize servo ${joint.servoId} (${joint.name}):`, error); + initialPositions.push(0); + joint.realValue = undefined; + } + } + + this.initialPositions = initialPositions; + + this._status = { + isConnected: true, + lastConnected: new Date(), + error: undefined + }; + this.notifyStatusChange(); + + console.log(`${this.name} connected successfully`); + + } catch (error) { + this._status = { + isConnected: false, + error: `Connection failed: ${error}` + }; + this.notifyStatusChange(); + throw error; + } + } + + async disconnect(): Promise { + console.log(`Disconnecting ${this.name}...`); + + if (!this._status.isConnected) return; + + try { + // Stop all joints safely + for (const joint of this.jointStates) { + if (!joint.servoId) continue; + + try { + if (joint.type === "continuous") { + await scsServoSDK.writeWheelSpeed(joint.servoId, 0); + } + await scsServoSDK.writeTorqueEnable(joint.servoId, false); + } catch (error) { + console.error(`Failed to stop servo ${joint.servoId}:`, error); + } + } + + await scsServoSDK.disconnect(); + + // Clear real values + this.jointStates.forEach(joint => { + joint.realValue = undefined; + }); + + this._status = { isConnected: false }; + this.notifyStatusChange(); + + console.log(`${this.name} disconnected`); + + } catch (error) { + this._status = { + isConnected: false, + error: `Disconnect failed: ${error}` + }; + this.notifyStatusChange(); + throw error; + } + } + + async executeCommand(command: RobotCommand): Promise { + if (!this._status.isConnected) { + throw new Error("Cannot execute command: USB slave not connected"); + } + + console.log(`USBSlave executing command with ${command.joints.length} joint updates`); + + for (const jointUpdate of command.joints) { + const joint = this.jointStates.find(j => j.name === jointUpdate.name); + if (!joint || !joint.servoId) continue; + + try { + if (joint.type === "continuous") { + await scsServoSDK.writeWheelSpeed(joint.servoId, jointUpdate.value); + joint.realValue = jointUpdate.value; + } else { + // Use relative positioning for revolute joints + const jointIndex = this.jointStates.indexOf(joint); + const relativeValue = (this.initialPositions[jointIndex] || 0) + jointUpdate.value; + + if (relativeValue >= 0 && relativeValue <= 360) { + const servoPosition = degreesToServoPosition(relativeValue); + await scsServoSDK.writePosition(joint.servoId, Math.round(servoPosition)); + joint.realValue = relativeValue; + } else { + throw new Error(`Position ${relativeValue}ยฐ out of range (0-360ยฐ)`); + } + } + + // Update virtual value to match what we sent + joint.virtualValue = jointUpdate.value; + + } catch (error) { + console.error(`Failed to execute command for joint ${joint.name}:`, error); + joint.realValue = undefined; + throw error; + } + } + + // Notify state update + this.notifyStateUpdate(); + } + + async executeCommands(commands: RobotCommand[]): Promise { + console.log(`USBSlave executing batch of ${commands.length} commands`); + + for (const command of commands) { + await this.executeCommand(command); + + // Small delay between commands to avoid overwhelming servos + if (commands.length > 1) { + await new Promise(resolve => setTimeout(resolve, 50)); + } + } + } + + async readJointStates(): Promise { + if (!this._status.isConnected) { + throw new Error("Cannot read states: USB slave not connected"); + } + + const states: DriverJointState[] = []; + + for (let i = 0; i < this.jointStates.length; i++) { + const joint = this.jointStates[i]; + + if (!joint.servoId) { + states.push({ ...joint }); + continue; + } + + try { + if (joint.type === "revolute") { + const servoPosition = await scsServoSDK.readPosition(joint.servoId); + const positionInDegrees = servoPositionToAngle(servoPosition); + + states.push({ + ...joint, + realValue: positionInDegrees + }); + } else { + // For continuous joints, we don't read speed (not available in feetech) + // Keep the last known value + states.push({ ...joint }); + } + } catch (error) { + console.error(`Failed to read servo ${joint.servoId}:`, error); + states.push({ + ...joint, + realValue: undefined + }); + } + } + + return states; + } + + async writeJointState(jointName: string, value: number): Promise { + const command: RobotCommand = { + timestamp: Date.now(), + joints: [{ name: jointName, value }] + }; + + await this.executeCommand(command); + } + + async writeJointStates(updates: { jointName: string; value: number }[]): Promise { + const command: RobotCommand = { + timestamp: Date.now(), + joints: updates.map(update => ({ name: update.jointName, value: update.value })) + }; + + await this.executeCommand(command); + } + + // Event subscription methods + onStateUpdate(callback: StateUpdateCallback): UnsubscribeFn { + this.stateCallbacks.push(callback); + return () => { + const index = this.stateCallbacks.indexOf(callback); + if (index >= 0) { + this.stateCallbacks.splice(index, 1); + } + }; + } + + onStatusChange(callback: StatusChangeCallback): UnsubscribeFn { + this.statusCallbacks.push(callback); + return () => { + const index = this.statusCallbacks.indexOf(callback); + if (index >= 0) { + this.statusCallbacks.splice(index, 1); + } + }; + } + + // Private methods + private notifyStateUpdate(): void { + this.stateCallbacks.forEach(callback => { + try { + callback([...this.jointStates]); + } catch (error) { + console.error("Error in state update callback:", error); + } + }); + } + + private notifyStatusChange(): void { + this.statusCallbacks.forEach(callback => { + try { + callback(this._status); + } catch (error) { + console.error("Error in status change callback:", error); + } + }); + } +} \ No newline at end of file diff --git a/src/lib/robot/drivers/WebSocketSlave.ts b/src/lib/robot/drivers/WebSocketSlave.ts new file mode 100644 index 0000000000000000000000000000000000000000..51573da4ab0c9c301525980344419d7742186289 --- /dev/null +++ b/src/lib/robot/drivers/WebSocketSlave.ts @@ -0,0 +1,372 @@ +import type { + SlaveDriver, + DriverJointState, + ConnectionStatus, + RobotCommand, + StateUpdateCallback, + StatusChangeCallback, + UnsubscribeFn +} from '$lib/types/robotDriver'; + +export interface WebSocketSlaveConfig { + type: "websocket-slave"; + url: string; + robotId: string; + apiKey?: string; +} + +/** + * WebSocket Slave Driver + * Connects to FastAPI WebSocket server as a slave to receive commands + */ +export class WebSocketSlave implements SlaveDriver { + readonly type = "slave" as const; + readonly id: string; + readonly name: string; + + private _status: ConnectionStatus = { isConnected: false }; + private config: WebSocketSlaveConfig; + + // WebSocket connection + private ws?: WebSocket; + private reconnectAttempts = 0; + private maxReconnectAttempts = 5; + private reconnectDelay = 1000; + + // Joint states + private jointStates: DriverJointState[] = []; + + // Event callbacks + private stateCallbacks: StateUpdateCallback[] = []; + private statusCallbacks: StatusChangeCallback[] = []; + + constructor(config: WebSocketSlaveConfig, initialJointStates: DriverJointState[]) { + this.config = config; + this.id = `websocket-slave-${Date.now()}`; + this.name = `WebSocket Slave (${config.robotId})`; + + // Initialize joint states + this.jointStates = initialJointStates.map(state => ({ ...state })); + + console.log(`Created WebSocketSlave for robot ${config.robotId} with ${this.jointStates.length} joints`); + } + + get status(): ConnectionStatus { + return this._status; + } + + async connect(): Promise { + console.log(`Connecting ${this.name} to ${this.config.url}...`); + + try { + // Build WebSocket URL + const wsUrl = this.buildWebSocketUrl(); + + // Create WebSocket connection + this.ws = new WebSocket(wsUrl); + + // Set up event handlers + this.setupWebSocketHandlers(); + + // Wait for connection + await this.waitForConnection(); + + this._status = { + isConnected: true, + lastConnected: new Date(), + error: undefined + }; + this.notifyStatusChange(); + + console.log(`${this.name} connected successfully`); + + } catch (error) { + this._status = { + isConnected: false, + error: `Connection failed: ${error}` + }; + this.notifyStatusChange(); + throw error; + } + } + + async disconnect(): Promise { + console.log(`Disconnecting ${this.name}...`); + + if (this.ws) { + this.ws.close(); + this.ws = undefined; + } + + this._status = { isConnected: false }; + this.notifyStatusChange(); + + console.log(`${this.name} disconnected`); + } + + async executeCommand(command: RobotCommand): Promise { + if (!this._status.isConnected) { + throw new Error("Cannot execute command: WebSocket slave not connected"); + } + + console.log(`WebSocketSlave executing command with ${command.joints.length} joint updates`); + + // Apply joint updates locally (for visualization) + for (const jointUpdate of command.joints) { + const joint = this.jointStates.find(j => j.name === jointUpdate.name); + if (joint) { + joint.virtualValue = jointUpdate.value; + joint.realValue = jointUpdate.value; // Simulate perfect execution + } + } + + // Send status update to server + await this.sendStatusUpdate(); + + // Notify state update + this.notifyStateUpdate(); + } + + async executeCommands(commands: RobotCommand[]): Promise { + console.log(`WebSocketSlave executing batch of ${commands.length} commands`); + + for (const command of commands) { + await this.executeCommand(command); + + // Small delay between commands + if (commands.length > 1) { + await new Promise(resolve => setTimeout(resolve, 50)); + } + } + } + + async readJointStates(): Promise { + if (!this._status.isConnected) { + throw new Error("Cannot read states: WebSocket slave not connected"); + } + + return [...this.jointStates]; + } + + async writeJointState(jointName: string, value: number): Promise { + const command: RobotCommand = { + timestamp: Date.now(), + joints: [{ name: jointName, value }] + }; + + await this.executeCommand(command); + } + + async writeJointStates(updates: { jointName: string; value: number }[]): Promise { + const command: RobotCommand = { + timestamp: Date.now(), + joints: updates.map(update => ({ name: update.jointName, value: update.value })) + }; + + await this.executeCommand(command); + } + + // Event subscription methods + onStateUpdate(callback: StateUpdateCallback): UnsubscribeFn { + this.stateCallbacks.push(callback); + return () => { + const index = this.stateCallbacks.indexOf(callback); + if (index >= 0) { + this.stateCallbacks.splice(index, 1); + } + }; + } + + onStatusChange(callback: StatusChangeCallback): UnsubscribeFn { + this.statusCallbacks.push(callback); + return () => { + const index = this.statusCallbacks.indexOf(callback); + if (index >= 0) { + this.statusCallbacks.splice(index, 1); + } + }; + } + + // Private methods + private buildWebSocketUrl(): string { + const baseUrl = this.config.url.replace(/^http/, 'ws'); + return `${baseUrl}/ws/slave/${this.config.robotId}`; + } + + private setupWebSocketHandlers(): void { + if (!this.ws) return; + + this.ws.onopen = () => { + console.log(`WebSocket slave connected for robot ${this.config.robotId}`); + this.reconnectAttempts = 0; // Reset on successful connection + }; + + this.ws.onmessage = (event) => { + try { + const message = JSON.parse(event.data); + this.handleServerMessage(message); + } catch (error) { + console.error("Failed to parse WebSocket message:", error); + } + }; + + this.ws.onclose = (event) => { + console.log(`WebSocket slave closed for robot ${this.config.robotId}:`, event.code, event.reason); + this.handleDisconnection(); + }; + + this.ws.onerror = (error) => { + console.error(`WebSocket slave error for robot ${this.config.robotId}:`, error); + this._status = { + isConnected: false, + error: `WebSocket error: ${error}` + }; + this.notifyStatusChange(); + }; + } + + private async waitForConnection(): Promise { + if (!this.ws) throw new Error("WebSocket not created"); + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Connection timeout")); + }, 10000); // 10 second timeout + + if (this.ws!.readyState === WebSocket.OPEN) { + clearTimeout(timeout); + resolve(); + return; + } + + this.ws!.onopen = () => { + clearTimeout(timeout); + resolve(); + }; + + this.ws!.onerror = (error) => { + clearTimeout(timeout); + reject(error); + }; + }); + } + + private async sendMessage(message: unknown): Promise { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + throw new Error("WebSocket not connected"); + } + + this.ws.send(JSON.stringify(message)); + } + + private async sendStatusUpdate(): Promise { + if (!this._status.isConnected) return; + + await this.sendMessage({ + type: "status_update", + timestamp: new Date().toISOString(), + data: { + isConnected: this._status.isConnected, + lastConnected: this._status.lastConnected?.toISOString(), + error: this._status.error + } + }); + } + + private async sendJointStates(): Promise { + if (!this._status.isConnected) return; + + await this.sendMessage({ + type: "joint_states", + timestamp: new Date().toISOString(), + data: this.jointStates + }); + } + + private handleServerMessage(message: unknown): void { + if (typeof message !== 'object' || message === null) return; + + const { type, data } = message as { type: string; data?: unknown }; + + switch (type) { + case 'execute_command': + if (data && typeof data === 'object') { + this.executeCommand(data as RobotCommand).catch(error => + console.error("Failed to execute command from server:", error) + ); + } + break; + + case 'execute_sequence': + if (data && typeof data === 'object') { + const sequence = data as { commands: RobotCommand[] }; + this.executeCommands(sequence.commands).catch(error => + console.error("Failed to execute sequence from server:", error) + ); + } + break; + + case 'stop_sequence': + console.log(`Stopping sequences on robot ${this.config.robotId}`); + // For now, just log - in a real implementation, this would cancel ongoing sequences + this.sendMessage({ + type: "status_update", + timestamp: new Date().toISOString(), + data: { message: "Sequences stopped", isConnected: true } + }).catch(error => console.error("Failed to send stop confirmation:", error)); + break; + + case 'sync_state': + console.log(`Received state sync for robot ${this.config.robotId}:`, data); + break; + + default: + console.warn(`Unknown message type from server: ${type}`); + } + } + + private handleDisconnection(): void { + this._status = { isConnected: false }; + this.notifyStatusChange(); + + // Attempt reconnection if not manually disconnected + if (this.reconnectAttempts < this.maxReconnectAttempts) { + this.attemptReconnection(); + } + } + + private async attemptReconnection(): Promise { + this.reconnectAttempts++; + const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1); // Exponential backoff + + console.log(`Attempting slave reconnection ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms...`); + + setTimeout(async () => { + try { + await this.connect(); + } catch (error) { + console.error(`Slave reconnection attempt ${this.reconnectAttempts} failed:`, error); + } + }, delay); + } + + private notifyStateUpdate(): void { + this.stateCallbacks.forEach(callback => { + try { + callback([...this.jointStates]); + } catch (error) { + console.error("Error in state update callback:", error); + } + }); + } + + private notifyStatusChange(): void { + this.statusCallbacks.forEach(callback => { + try { + callback(this._status); + } catch (error) { + console.error("Error in status change callback:", error); + } + }); + } +} \ No newline at end of file diff --git a/src/lib/runes/env.svelte.ts b/src/lib/runes/env.svelte.ts new file mode 100644 index 0000000000000000000000000000000000000000..22bbcaa6dd6c7a0543b05f0126b087c5a2f9bb7d --- /dev/null +++ b/src/lib/runes/env.svelte.ts @@ -0,0 +1,11 @@ +import type { RobotState } from "$lib/types/robot"; + +interface Environment { + robots: { + [robotId: string]: RobotState; + } +} + +export const environment: Environment = $state({ + robots: {} +}); \ No newline at end of file diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..a397ac1fb235eb74ec78ad5ea115e697df7048be --- /dev/null +++ b/src/lib/types/index.ts @@ -0,0 +1 @@ +export * from './settings'; \ No newline at end of file diff --git a/src/lib/types/robot.ts b/src/lib/types/robot.ts new file mode 100644 index 0000000000000000000000000000000000000000..650cf68baa10b0922088ff66fbd197ee6bb4320b --- /dev/null +++ b/src/lib/types/robot.ts @@ -0,0 +1,15 @@ +import type IUrdfRobot from "$lib/components/scene/robot/URDF/interfaces/IUrdfRobot"; +import type IUrdfJoint from "$lib/components/scene/robot/URDF/interfaces/IUrdfJoint"; +import type IUrdfLink from "$lib/components/scene/robot/URDF/interfaces/IUrdfLink"; +import type { RobotUrdfConfig } from "./urdf"; + +// Main URDF state interface +export interface RobotState { + robot: IUrdfRobot; + urdfConfig: RobotUrdfConfig; + selection: { + isSelected: boolean; + selectedLink?: IUrdfLink; + selectedJoint?: IUrdfJoint; + } +} diff --git a/src/lib/types/robotDriver.ts b/src/lib/types/robotDriver.ts new file mode 100644 index 0000000000000000000000000000000000000000..4ee666152e1772739dfcfd10b1c5eca7f03a3a6b --- /dev/null +++ b/src/lib/types/robotDriver.ts @@ -0,0 +1,193 @@ +/** + * Robot Driver Architecture v2.0 - Master/Slave Pattern + * + * Masters: Provide commands (remote servers, scripts, manual control) + * Slaves: Execute commands (physical robots, simulators) + */ + +export interface DriverJointState { + name: string; + servoId: number; + type: "revolute" | "continuous"; + virtualValue: number; // Commanded value + realValue?: number; // Actual hardware value + limits?: { + lower?: number; + upper?: number; + velocity?: number; + effort?: number; + }; +} + +export interface ConnectionStatus { + isConnected: boolean; + lastConnected?: Date; + error?: string; +} + +/** + * Robot command for joint control + */ +export interface RobotCommand { + timestamp: number; + joints: { + name: string; + value: number; + speed?: number; // Optional movement speed + }[]; + duration?: number; // Optional command duration + metadata?: Record; // Additional command data +} + +/** + * Command sequence for complex movements + */ +export interface CommandSequence { + id: string; + name: string; + commands: RobotCommand[]; + loop?: boolean; + totalDuration: number; +} + +// Callback types +export type StateUpdateCallback = (states: DriverJointState[]) => void; +export type StatusChangeCallback = (status: ConnectionStatus) => void; +export type CommandCallback = (commands: RobotCommand[]) => void; +export type SequenceCallback = (sequence: CommandSequence) => void; +export type UnsubscribeFn = () => void; + +/** + * Base driver interface - common functionality + */ +export interface BaseRobotDriver { + readonly id: string; + readonly type: "master" | "slave"; + readonly name: string; + readonly status: ConnectionStatus; + + connect(): Promise; + disconnect(): Promise; + onStatusChange(callback: StatusChangeCallback): UnsubscribeFn; +} + +/** + * Master Driver - Provides commands to control robots + * Examples: Remote server, script player, manual control panel + */ +export interface MasterDriver extends BaseRobotDriver { + readonly type: "master"; + + // Command generation + onCommand(callback: CommandCallback): UnsubscribeFn; + onSequence(callback: SequenceCallback): UnsubscribeFn; + + // Master control + start(): Promise; + stop(): Promise; + pause(): Promise; + resume(): Promise; +} + +/** + * Slave Driver - Executes commands on physical/virtual robots + * Examples: USB robot, mock robot, simulation + */ +export interface SlaveDriver extends BaseRobotDriver { + readonly type: "slave"; + + // Command execution + executeCommand(command: RobotCommand): Promise; + executeCommands(commands: RobotCommand[]): Promise; + + // State monitoring + readJointStates(): Promise; + onStateUpdate(callback: StateUpdateCallback): UnsubscribeFn; + + // Individual joint control (for manual override) + writeJointState(jointName: string, value: number): Promise; + writeJointStates(updates: { jointName: string; value: number }[]): Promise; +} + +/** + * Master driver configurations + */ +export interface RemoteServerMasterConfig { + type: "remote-server"; + url: string; + apiKey?: string; + pollInterval?: number; +} + +export interface MockSequenceMasterConfig { + type: "mock-sequence"; + sequences: CommandSequence[]; + autoStart?: boolean; + loopMode?: boolean; +} + +export interface USBMasterConfig { + type: "usb-master"; + port?: string; + baudRate?: number; + pollInterval?: number; + smoothing?: boolean; +} + +export interface ScriptPlayerMasterConfig { + type: "script-player"; + scriptUrl: string; + variables?: Record; +} + +export type MasterDriverConfig = + | RemoteServerMasterConfig + | MockSequenceMasterConfig + | USBMasterConfig + | ScriptPlayerMasterConfig; + +/** + * Slave driver configurations + */ +export interface USBSlaveConfig { + type: "usb-slave"; + port?: string; + baudRate?: number; +} + +export interface MockSlaveConfig { + type: "mock-slave"; + simulateLatency?: number; + simulateErrors?: boolean; + responseDelay?: number; +} + +export interface SimulationSlaveConfig { + type: "simulation-slave"; + physics?: boolean; + collisionDetection?: boolean; +} + +export interface RemoteServerSlaveConfig { + type: "remote-server-slave"; + url: string; + apiKey?: string; + robotId: string; +} + +export type SlaveDriverConfig = + | USBSlaveConfig + | MockSlaveConfig + | SimulationSlaveConfig + | RemoteServerSlaveConfig; + +/** + * Combined driver configuration for the UI + */ +export type RobotDriverConfig = { + role: "master"; + config: MasterDriverConfig; +} | { + role: "slave"; + config: SlaveDriverConfig; +}; \ No newline at end of file diff --git a/src/lib/types/settings.ts b/src/lib/types/settings.ts new file mode 100644 index 0000000000000000000000000000000000000000..e0390fd51031b6a986c794f38e46e8d1720fa8cc --- /dev/null +++ b/src/lib/types/settings.ts @@ -0,0 +1,6 @@ +export interface Settings { + camera: { + position: [number, number, number]; + fov: number; + } +} \ No newline at end of file diff --git a/src/lib/types/urdf.ts b/src/lib/types/urdf.ts new file mode 100644 index 0000000000000000000000000000000000000000..7f5d61e991a2a7eec5d287e4875ee350c50a0c1e --- /dev/null +++ b/src/lib/types/urdf.ts @@ -0,0 +1,26 @@ +export interface UrdfCompoundMovement { + name: string; + primaryJoint: number; // the joint controlled by the key + // Optional formula for calculating deltaPrimary, can use primary, dependent, etc. + primaryFormula?: string; + dependents: { + joint: number; + // The formula is used to calculate the delta for the dependent joint (deltaDependent) + // It can use variables: primary, dependent, deltaPrimary + // deltaPrimary itself can depend on primary and dependent angles + // Example: "deltaPrimary * 0.8 + primary * 0.1 - dependent * 0.05" + formula: string; + }[]; +} + +export type RobotUrdfConfig = { + urdfUrl: string; + jointNameIdMap?: { + [key: string]: number; + }; + compoundMovements?: UrdfCompoundMovement[]; + // Rest/calibration position - joint values in degrees when robot is in "rest mode" + restPosition?: { + [jointName: string]: number; + }; +}; \ No newline at end of file diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..a9e2ca390f4c4d482adfa3b49c581a0a81fc415b --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,134 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +/** + * Combines and optimizes CSS class names using clsx and tailwind-merge + * + * This utility function combines multiple class names and resolves + * Tailwind CSS conflicts by keeping the last conflicting class. + * + * @param inputs - Class values to combine (strings, objects, arrays) + * @returns Optimized class name string + * + * @example + * cn('px-2 py-1', 'px-4') // Result: 'py-1 px-4' + * cn('text-red-500', condition && 'text-blue-500') // Conditional classes + */ +export function cn(...inputs: ClassValue[]): string { + return twMerge(clsx(inputs)); +} + +// === Servo Position and Angle Conversion Functions === + +/** + * Converts a servo position to an angle in degrees + * + * Servo positions range from 0 to 4096, representing a full 360ยฐ rotation. + * This function maps the position to its corresponding angle. + * + * @param position - The servo position (0 to 4096) + * @returns The corresponding angle (0 to 360 degrees) + * + * @example + * servoPositionToAngle(0) // Returns: 0 + * servoPositionToAngle(2048) // Returns: 180 + * servoPositionToAngle(4096) // Returns: 360 + */ +export function servoPositionToAngle(position: number): number { + return (position / 4096) * 360; +} + +/** + * Converts degrees to a servo position + * + * Maps angle in degrees to the corresponding servo position value. + * Clamps the result to the valid servo range (0-4096). + * + * @param degrees - The angle in degrees (0 to 360) + * @returns The corresponding servo position (0 to 4096) + * + * @example + * degreesToServoPosition(0) // Returns: 0 + * degreesToServoPosition(180) // Returns: 2048 + * degreesToServoPosition(360) // Returns: 4096 + */ +export function degreesToServoPosition(degrees: number): number { + return Math.min(Math.round((degrees * 4096) / 360), 4096); +} + +// === Angle Unit Conversion Functions === + +/** + * Converts radians to degrees + * + * @param radians - The angle in radians + * @returns The angle in degrees + * + * @example + * radiansToDegrees(Math.PI) // Returns: 180 + * radiansToDegrees(Math.PI / 2) // Returns: 90 + */ +export function radiansToDegrees(radians: number): number { + return (radians * 180) / Math.PI; +} + +/** + * Converts degrees to radians + * + * @param degrees - The angle in degrees + * @returns The angle in radians + * + * @example + * degreesToRadians(180) // Returns: Math.PI + * degreesToRadians(90) // Returns: Math.PI / 2 + */ +export function degreesToRadians(degrees: number): number { + return (degrees * Math.PI) / 180; +} + +/** + * Converts radians to a servo position + * + * Combines radian-to-degree conversion with servo position mapping. + * Useful for direct conversion from joint angles to servo commands. + * + * @param radians - The angle in radians + * @returns The corresponding servo position (0 to 4096) + * + * @example + * radiansToServoPosition(0) // Returns: 0 + * radiansToServoPosition(Math.PI) // Returns: 2048 + * radiansToServoPosition(2*Math.PI) // Returns: 4096 + */ +export function radiansToServoPosition(radians: number): number { + return Math.min(Math.round((radians * 4096) / (2 * Math.PI)), 4096); +} + +// === TypeScript Utility Types === + +/** + * Utility type that removes the 'child' property from a type + * Useful for component prop interfaces that shouldn't accept child elements + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type WithoutChild = T extends { child?: any } ? Omit : T; + +/** + * Utility type that removes the 'children' property from a type + * Useful for component prop interfaces that shouldn't accept children + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type WithoutChildren = T extends { children?: any } ? Omit : T; + +/** + * Utility type that removes both 'child' and 'children' properties + * Combines WithoutChild and WithoutChildren for maximum flexibility + */ +export type WithoutChildrenOrChild = WithoutChildren>; + +/** + * Utility type that adds an optional ref property for HTML element references + * @template T - The base type to extend + * @template U - The HTML element type (defaults to HTMLElement) + */ +export type WithElementRef = T & { ref?: U | null }; diff --git a/src/lib/utils/robotOptimizations.ts b/src/lib/utils/robotOptimizations.ts new file mode 100644 index 0000000000000000000000000000000000000000..18a457f6df2f64b9f84b596f5bc785a122256407 --- /dev/null +++ b/src/lib/utils/robotOptimizations.ts @@ -0,0 +1,387 @@ +/** + * Advanced Robot Optimization Utilities + * Inspired by lerobot techniques for high-performance robot control + */ + +import { getSafetyConfig, getTimingConfig, getLoggingConfig } from '$lib/configs/performanceConfig'; +import type { RobotCommand } from '$lib/types/robotDriver'; + +/** + * Performance timing utilities (inspired by lerobot's perf_counter usage) + */ +export class PerformanceTimer { + private startTime: number; + private logs: Map = new Map(); + + constructor() { + this.startTime = performance.now(); + } + + /** + * Mark a timing checkpoint + */ + mark(label: string): void { + const now = performance.now(); + this.logs.set(label, now - this.startTime); + this.startTime = now; + } + + /** + * Get timing for a specific label + */ + getTime(label: string): number | undefined { + return this.logs.get(label); + } + + /** + * Get all timing data + */ + getAllTimings(): Map { + return new Map(this.logs); + } + + /** + * Log performance data in lerobot style + */ + logPerformance(prefix: string = "robot"): void { + if (!getLoggingConfig().ENABLE_TIMING_MEASUREMENTS) return; + + const items: string[] = []; + for (const [label, timeMs] of this.logs) { + const hz = 1000 / timeMs; + items.push(`${label}:${timeMs.toFixed(2)}ms (${hz.toFixed(1)}Hz)`); + } + + if (items.length > 0) { + console.log(`${prefix} ${items.join(' ')}`); + } + } + + /** + * Reset timing data + */ + reset(): void { + this.logs.clear(); + this.startTime = performance.now(); + } +} + +/** + * Safety position clamping (inspired by lerobot's ensure_safe_goal_position) + * Prevents sudden large movements that could damage the robot + */ +export function ensureSafeGoalPosition( + goalPosition: number, + currentPosition: number, + maxRelativeTarget?: number +): number { + const safetyConfig = getSafetyConfig(); + + if (!safetyConfig.ENABLE_POSITION_CLAMPING) { + return goalPosition; + } + + const maxTarget = maxRelativeTarget || safetyConfig.MAX_RELATIVE_TARGET_DEG; + const deltaPosition = goalPosition - currentPosition; + + // Check for emergency stop condition + if (Math.abs(deltaPosition) > safetyConfig.EMERGENCY_STOP_THRESHOLD_DEG) { + console.warn( + `โš ๏ธ Emergency stop: movement too large (${deltaPosition.toFixed(1)}ยฐ > ${safetyConfig.EMERGENCY_STOP_THRESHOLD_DEG}ยฐ)` + ); + return currentPosition; // Don't move at all + } + + // Clamp to maximum relative movement + if (Math.abs(deltaPosition) > maxTarget) { + const clampedPosition = currentPosition + Math.sign(deltaPosition) * maxTarget; + console.log( + `๐Ÿ›ก๏ธ Safety clamp: ${goalPosition.toFixed(1)}ยฐ โ†’ ${clampedPosition.toFixed(1)}ยฐ (max: ยฑ${maxTarget}ยฐ)` + ); + return clampedPosition; + } + + return goalPosition; +} + +/** + * Velocity limiting (inspired by lerobot's joint velocity constraints) + */ +export function limitJointVelocity( + currentPosition: number, + goalPosition: number, + deltaTimeMs: number, + maxVelocityDegS?: number +): number { + const safetyConfig = getSafetyConfig(); + const maxVel = maxVelocityDegS || safetyConfig.MAX_JOINT_VELOCITY_DEG_S; + + const deltaPosition = goalPosition - currentPosition; + const deltaTimeS = deltaTimeMs / 1000; + const requiredVelocity = Math.abs(deltaPosition) / deltaTimeS; + + if (requiredVelocity > maxVel) { + const maxMovement = maxVel * deltaTimeS; + const limitedPosition = currentPosition + Math.sign(deltaPosition) * maxMovement; + + console.log( + `๐ŸŒ Velocity limit: ${requiredVelocity.toFixed(1)}ยฐ/s โ†’ ${maxVel}ยฐ/s (pos: ${limitedPosition.toFixed(1)}ยฐ)` + ); + + return limitedPosition; + } + + return goalPosition; +} + +/** + * Busy wait for precise timing (inspired by lerobot's busy_wait function) + * More accurate than setTimeout for high-frequency control loops + */ +export async function busyWait(durationMs: number): Promise { + const timingConfig = getTimingConfig(); + + if (!timingConfig.USE_BUSY_WAIT || durationMs <= 0) { + return; + } + + const startTime = performance.now(); + const targetTime = startTime + durationMs; + + // Use a combination of setTimeout and busy waiting for efficiency + if (durationMs > 5) { + // For longer waits, use setTimeout for most of the duration + await new Promise(resolve => setTimeout(resolve, durationMs - 2)); + } + + // Busy wait for the remaining time for high precision + while (performance.now() < targetTime) { + // Busy loop - more accurate than setTimeout for short durations + } +} + +/** + * Frame rate controller with precise timing + */ +export class FrameRateController { + private lastFrameTime: number; + private targetFrameTimeMs: number; + private frameCount: number = 0; + private performanceTimer: PerformanceTimer; + + constructor(targetFps: number) { + this.targetFrameTimeMs = 1000 / targetFps; + this.lastFrameTime = performance.now(); + this.performanceTimer = new PerformanceTimer(); + } + + /** + * Wait until the next frame should start + */ + async waitForNextFrame(): Promise { + const now = performance.now(); + const elapsed = now - this.lastFrameTime; + const remaining = this.targetFrameTimeMs - elapsed; + + if (remaining > 0) { + await busyWait(remaining); + } + + this.lastFrameTime = performance.now(); + this.frameCount++; + + // Log performance periodically + if (this.frameCount % 60 === 0) { + const actualFrameTime = now - this.lastFrameTime + remaining; + const actualFps = 1000 / actualFrameTime; + this.performanceTimer.logPerformance(`frame_rate: ${actualFps.toFixed(1)}fps`); + } + } + + /** + * Get current frame timing info + */ + getFrameInfo(): { frameCount: number; actualFps: number; targetFps: number } { + const now = performance.now(); + const actualFrameTime = now - this.lastFrameTime; + const actualFps = 1000 / actualFrameTime; + const targetFps = 1000 / this.targetFrameTimeMs; + + return { + frameCount: this.frameCount, + actualFps, + targetFps + }; + } +} + +/** + * Batch command processor for efficient joint updates + */ +export class BatchCommandProcessor { + private commandQueue: RobotCommand[] = []; + private batchSize: number; + private processingInterval?: number; + + constructor(batchSize: number = 10, processingIntervalMs: number = 16) { + this.batchSize = batchSize; + this.startProcessing(processingIntervalMs); + } + + /** + * Add a command to the batch queue + */ + queueCommand(command: RobotCommand): void { + this.commandQueue.push(command); + + // Process immediately if batch is full + if (this.commandQueue.length >= this.batchSize) { + this.processBatch(); + } + } + + /** + * Process a batch of commands + */ + private processBatch(): RobotCommand[] { + if (this.commandQueue.length === 0) return []; + + const batch = this.commandQueue.splice(0, this.batchSize); + + // Merge commands for the same joints (latest wins) + const mergedJoints = new Map(); + + for (const command of batch) { + for (const joint of command.joints) { + mergedJoints.set(joint.name, joint); + } + } + + const mergedCommand: RobotCommand = { + timestamp: Date.now(), + joints: Array.from(mergedJoints.values()), + metadata: { source: "batch_processor", batchSize: batch.length } + }; + + return [mergedCommand]; + } + + /** + * Start automatic batch processing + */ + private startProcessing(intervalMs: number): void { + this.processingInterval = setInterval(() => { + if (this.commandQueue.length > 0) { + this.processBatch(); + } + }, intervalMs); + } + + /** + * Stop batch processing + */ + stop(): void { + if (this.processingInterval) { + clearInterval(this.processingInterval); + this.processingInterval = undefined; + } + } + + /** + * Get queue status + */ + getQueueStatus(): { queueLength: number; batchSize: number } { + return { + queueLength: this.commandQueue.length, + batchSize: this.batchSize + }; + } +} + +/** + * Connection health monitor + */ +export class ConnectionHealthMonitor { + private lastSuccessTime: number; + private errorCount: number = 0; + private healthCheckInterval?: number; + + constructor(healthCheckIntervalMs: number = 1000) { + this.lastSuccessTime = Date.now(); + this.startHealthCheck(healthCheckIntervalMs); + } + + /** + * Report a successful operation + */ + reportSuccess(): void { + this.lastSuccessTime = Date.now(); + this.errorCount = 0; + } + + /** + * Report an error + */ + reportError(): void { + this.errorCount++; + } + + /** + * Check if connection is healthy + */ + isHealthy(maxErrorCount: number = 5, maxSilenceMs: number = 5000): boolean { + const timeSinceLastSuccess = Date.now() - this.lastSuccessTime; + return this.errorCount < maxErrorCount && timeSinceLastSuccess < maxSilenceMs; + } + + /** + * Get health metrics + */ + getHealthMetrics(): { errorCount: number; timeSinceLastSuccessMs: number; isHealthy: boolean } { + return { + errorCount: this.errorCount, + timeSinceLastSuccessMs: Date.now() - this.lastSuccessTime, + isHealthy: this.isHealthy() + }; + } + + /** + * Start health monitoring + */ + private startHealthCheck(intervalMs: number): void { + this.healthCheckInterval = setInterval(() => { + const metrics = this.getHealthMetrics(); + if (!metrics.isHealthy) { + console.warn( + `โš ๏ธ Connection health warning: ${metrics.errorCount} errors, ${metrics.timeSinceLastSuccessMs}ms since last success` + ); + } + }, intervalMs); + } + + /** + * Stop health monitoring + */ + stop(): void { + if (this.healthCheckInterval) { + clearInterval(this.healthCheckInterval); + this.healthCheckInterval = undefined; + } + } +} + +/** + * Async operation timeout utility + */ +export function withTimeout( + promise: Promise, + timeoutMs: number, + operation: string = "operation" +): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`${operation} timed out after ${timeoutMs}ms`)), timeoutMs) + ) + ]); +} \ No newline at end of file diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte new file mode 100644 index 0000000000000000000000000000000000000000..666ad7077ec6c39ba1e50718cc5c22394cbf7a38 --- /dev/null +++ b/src/routes/+layout.svelte @@ -0,0 +1,9 @@ + + +
+ {@render children()} +
diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts new file mode 100644 index 0000000000000000000000000000000000000000..c8cacf0895342e5b27842850d40b3efc28f5a4ab --- /dev/null +++ b/src/routes/+layout.ts @@ -0,0 +1 @@ +export const prerender = true; \ No newline at end of file diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte new file mode 100644 index 0000000000000000000000000000000000000000..0a2df05ac0f28d7e92af27729c8266761bf785f3 --- /dev/null +++ b/src/routes/+page.svelte @@ -0,0 +1,92 @@ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/static/favicon.png b/static/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..825b9e65af7c104cfb07089bb28659393b4f2097 Binary files /dev/null and b/static/favicon.png differ diff --git a/static/robots/so-100/meshes/Base.stl b/static/robots/so-100/meshes/Base.stl new file mode 100755 index 0000000000000000000000000000000000000000..03bc1a5d0bbba5330f4833a2ee89fdc8825b91bf --- /dev/null +++ b/static/robots/so-100/meshes/Base.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb8afb0303c0125f265a797625cfaf646ac214d5d2dc9c986348c415755e2a74 +size 443484 diff --git a/static/robots/so-100/meshes/Base_Motor.stl b/static/robots/so-100/meshes/Base_Motor.stl new file mode 100755 index 0000000000000000000000000000000000000000..bc3f3a63ce1d14560ec72a7fe8cc65c9af5990e2 --- /dev/null +++ b/static/robots/so-100/meshes/Base_Motor.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aac4485d448eca418d2a93df3f78f449bd59b21ec1af2a579171819fd44ea576 +size 154284 diff --git a/static/robots/so-100/meshes/Fixed_Jaw.stl b/static/robots/so-100/meshes/Fixed_Jaw.stl new file mode 100755 index 0000000000000000000000000000000000000000..4bb2138be0e72d20b944df51984aeb9fcbe17e03 --- /dev/null +++ b/static/robots/so-100/meshes/Fixed_Jaw.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5528867073033eaa4c27a8087180954f3f0635bd69d517d5cace92f01dd266b2 +size 264584 diff --git a/static/robots/so-100/meshes/Fixed_Jaw_Motor.stl b/static/robots/so-100/meshes/Fixed_Jaw_Motor.stl new file mode 100755 index 0000000000000000000000000000000000000000..98bb0ad86391d635384ab5cb3e3e1ff84843135b --- /dev/null +++ b/static/robots/so-100/meshes/Fixed_Jaw_Motor.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0321bfb902d5a119c4f2a934ab4be7e9ea8c93a02e1685f066f99c24a5ca3ecf +size 151884 diff --git a/static/robots/so-100/meshes/Lower_Arm.stl b/static/robots/so-100/meshes/Lower_Arm.stl new file mode 100755 index 0000000000000000000000000000000000000000..e4d68753a33891eb9b9e56d7ee38961e5109f82e --- /dev/null +++ b/static/robots/so-100/meshes/Lower_Arm.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a04511bc65cbb702b2d6023764de947888b06a09fbf272803f39c22724329a3e +size 347184 diff --git a/static/robots/so-100/meshes/Lower_Arm_Motor.stl b/static/robots/so-100/meshes/Lower_Arm_Motor.stl new file mode 100755 index 0000000000000000000000000000000000000000..bb7c71a30651879d3f5d8d48ce7e08b7164d1c7d --- /dev/null +++ b/static/robots/so-100/meshes/Lower_Arm_Motor.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:07368fa4c50dea70e8157accee71072120c0cb374f672c85aba1d081702bc476 +size 151884 diff --git a/static/robots/so-100/meshes/Moving_Jaw.stl b/static/robots/so-100/meshes/Moving_Jaw.stl new file mode 100755 index 0000000000000000000000000000000000000000..20bfe32f7ba11e39dadd6a4c4f51ba6386922e50 --- /dev/null +++ b/static/robots/so-100/meshes/Moving_Jaw.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6d9c923024c75e6f0a8d8300317c4d8a17dd49880260cbc60b7fe5e0c6dadfa8 +size 177184 diff --git a/static/robots/so-100/meshes/Rotation_Pitch.stl b/static/robots/so-100/meshes/Rotation_Pitch.stl new file mode 100755 index 0000000000000000000000000000000000000000..1035e7915b034c95306b413804e800e87fed1fd0 --- /dev/null +++ b/static/robots/so-100/meshes/Rotation_Pitch.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4a1476f57d28d2c065bc0ede6a674cb3ebacf1193372916ef1e0ff32eeb92cf0 +size 347584 diff --git a/static/robots/so-100/meshes/Rotation_Pitch_Motor.stl b/static/robots/so-100/meshes/Rotation_Pitch_Motor.stl new file mode 100755 index 0000000000000000000000000000000000000000..61d6fccc7b7ea29f98d227a92de57c8109df7c06 --- /dev/null +++ b/static/robots/so-100/meshes/Rotation_Pitch_Motor.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f4fbcf18d8f2e91d12a767c1679430140dce0c81ea1478cc153bfce3a162db61 +size 154284 diff --git a/static/robots/so-100/meshes/Upper_Arm.stl b/static/robots/so-100/meshes/Upper_Arm.stl new file mode 100755 index 0000000000000000000000000000000000000000..273e98a4b1a51d5b3aa1724132e2f2e31355816f --- /dev/null +++ b/static/robots/so-100/meshes/Upper_Arm.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d97ad37c90400fe39bb2d5ace8b4290f3d1262e13f1d4f166494421752d5f3b7 +size 215184 diff --git a/static/robots/so-100/meshes/Upper_Arm_Motor.stl b/static/robots/so-100/meshes/Upper_Arm_Motor.stl new file mode 100755 index 0000000000000000000000000000000000000000..7a195a05c6da71ccbcc0d0e059c071787636e412 --- /dev/null +++ b/static/robots/so-100/meshes/Upper_Arm_Motor.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bafdb2cb9e9a884e62b5fbf15b1f706a65771da82764dbe703df128d9f34d723 +size 152284 diff --git a/static/robots/so-100/meshes/Wrist_Pitch_Roll.stl b/static/robots/so-100/meshes/Wrist_Pitch_Roll.stl new file mode 100755 index 0000000000000000000000000000000000000000..90f30820a9f77552dfc7d24147b06323c25b8839 --- /dev/null +++ b/static/robots/so-100/meshes/Wrist_Pitch_Roll.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:58e83ce1c8b24e1fa3a79672ea965ea4143986c81a21386dcd6da1cbcfdc94bb +size 341684 diff --git a/static/robots/so-100/meshes/Wrist_Pitch_Roll_Motor.stl b/static/robots/so-100/meshes/Wrist_Pitch_Roll_Motor.stl new file mode 100755 index 0000000000000000000000000000000000000000..d010f37a43799b8f384fdd0198724bf3372b8288 --- /dev/null +++ b/static/robots/so-100/meshes/Wrist_Pitch_Roll_Motor.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:89ac3fb1d2536fa4f7c40e7449be4b934d7d776f9cd0453c842767f980ff6c18 +size 133684 diff --git a/static/robots/so-100/so_arm100.urdf b/static/robots/so-100/so_arm100.urdf new file mode 100644 index 0000000000000000000000000000000000000000..ab7927596651d70ab9ab7bb507d0ec0b9680beb3 --- /dev/null +++ b/static/robots/so-100/so_arm100.urdf @@ -0,0 +1,255 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + -1.9 + 1.9 + + + + + + + + + + + 0.1 + 3.4 + + + + + + + + + + + -3.0 + -0.1 + + + + + + + + + + + -2.4 + 1.1 + + + + + + + + + + + -3.0 + 3.0 + + + + + + + + + + + -0.1 + 1.9 + + + diff --git a/svelte.config.js b/svelte.config.js new file mode 100644 index 0000000000000000000000000000000000000000..4fbc8671c30f646380a0c1d36b11602ab4d5c5e9 --- /dev/null +++ b/svelte.config.js @@ -0,0 +1,18 @@ +import adapter from '@sveltejs/adapter-static'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + // Consult https://svelte.dev/docs/kit/integrations + // for more information about preprocessors + preprocess: vitePreprocess(), + + kit: { + // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. + // If your environment is not supported, or you settled on a specific environment, switch out the adapter. + // See https://svelte.dev/docs/kit/adapters for more information about adapters. + adapter: adapter() + } +}; + +export default config; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..0b2d8865f4efe30f84b6516a5de8d6bd6b927fb6 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes + // from the referenced tsconfig.json - TypeScript does not merge them in +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..2d35c4f5af9151490b7567a40461d3430a9451b8 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,7 @@ +import tailwindcss from '@tailwindcss/vite'; +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()] +});