commit db1d565b6441efa0b9a2b619dd4730da2f7513b4 Author: wehub-resource-sync Date: Mon Jul 13 12:36:23 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..d01cc5d --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,72 @@ +FROM osrf/ros:humble-desktop + +# Install dev tools, ROS2 extras, git, Python and pip +RUN apt-get update && apt-get install -y \ + python3 \ + python3-colcon-common-extensions \ + python3-rosdep \ + python3-vcstool \ + python3-pip \ + git \ + build-essential \ + ros-humble-rosbridge-server \ + # GUI + X11/WSL2 compatibility + x11-apps \ + libx11-xcb1 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 \ + libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 \ + mesa-utils \ + libgl1-mesa-glx \ + libglu1-mesa \ + libxext6 \ + libxrender1 \ + libxrandr2 \ + libxi6 \ + && rm -rf /var/lib/apt/lists/* + +# Ensure pip is accessible as `pip` +RUN ln -sf /usr/bin/pip3 /usr/bin/pip + +# Initialize rosdep +RUN rosdep init || true && rosdep update + +# Install Ruff, pre-commit, uv globally +RUN pip install --no-cache-dir ruff pre-commit uv + +# Add non-root user for VSCode devcontainer +ARG USERNAME=vscode +ARG USER_UID=1000 +ARG USER_GID=$USER_UID + +RUN groupadd --gid $USER_GID $USERNAME \ + && useradd --uid $USER_UID --gid $USER_GID -m $USERNAME \ + && echo "$USERNAME ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers.d/$USERNAME \ + && chmod 0440 /etc/sudoers.d/$USERNAME + +# Auto-source ROS2 for all users +RUN echo "source /opt/ros/humble/setup.bash" >> /etc/bash.bashrc + +# Set locale +ENV LANG=C.UTF-8 +ENV LC_ALL=C.UTF-8 + +# Ensure Qt apps can run +ENV QT_X11_NO_MITSHM=1 +ENV QT_QPA_PLATFORM=xcb +ENV LIBGL_ALWAYS_SOFTWARE=1 +ENV QT_XCB_GL_INTEGRATION=none + +# Create a workspace for the non-root user +RUN mkdir -p /home/$USERNAME/workspace \ + && chown -R $USERNAME:$USERNAME /home/$USERNAME/workspace + +# Set workspace directory +WORKDIR /home/$USERNAME/workspace + +# Switch to non-root user by default in devcontainer +USER $USERNAME + +# Allow pre-commit to run even if no config exists +ENV PRE_COMMIT_ALLOW_NO_CONFIG=1 + +RUN git init . \ + && pre-commit install --allow-missing-config || true \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..336ea67 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,47 @@ +{ + "name": "ROS2 Humble Dev", + "build": { "dockerfile": "Dockerfile" }, + "workspaceFolder": "/home/vscode/workspace", + "mounts": [ + "source=${localWorkspaceFolder},target=/home/vscode/workspace,type=bind" + ], + "runArgs": [ + "-v", "${env:SSH_AUTH_SOCK}:/ssh-agent", + "-e", "SSH_AUTH_SOCK=/ssh-agent", + "-e", "DISPLAY=${localEnv:DISPLAY}", + "-e", "LIBGL_ALWAYS_SOFTWARE=1", + "-e", "QT_XCB_GL_INTEGRATION=none", + "-v", "/tmp/.X11-unix:/tmp/.X11-unix:rw" + ], + "containerEnv": { + "QT_X11_NO_MITSHM": "1", + "QT_QPA_PLATFORM": "xcb", + "DISPLAY": "${localEnv:DISPLAY}", + "SSH_AUTH_SOCK": "/ssh-agent", + "LIBGL_ALWAYS_SOFTWARE": "1", + "QT_XCB_GL_INTEGRATION": "none", + "TERM": "xterm-256color" + }, + "remoteUser": "vscode", + "postStartCommand": "xhost +local:vscode || true", + "forwardPorts": [5000, 9000, 9090], + "portsAttributes": { + "9000": { "label": "MCP Server", "onAutoForward": "ignore" }, + "9090": { "label": "ROSBridge", "onAutoForward": "ignore" } + }, + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "ms-azuretools.vscode-docker", + "twxs.cmake", + "ms-vscode.cpptools", + "ms-python.vscode-pylance", + "astral-sh.ruff" + ], + "settings": { + "terminal.integrated.defaultProfile.linux": "bash" + } + } + } +} \ No newline at end of file diff --git a/.github/workflows/clone-metrics.yml b/.github/workflows/clone-metrics.yml new file mode 100644 index 0000000..6e82bac --- /dev/null +++ b/.github/workflows/clone-metrics.yml @@ -0,0 +1,151 @@ +name: Track Clone Metrics + +on: + workflow_dispatch: + schedule: + - cron: '0 8 * * *' # Run every day at 8am + + +jobs: + clone-stats: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Fetch all history for proper branch operations + + - name: Generate GitHub App token + id: generate_token + uses: tibdex/github-app-token@v2.1.0 + with: + app_id: ${{ secrets.APP_ID }} + private_key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Switch to metrics branch + run: | + # Checkout or create metrics branch + if git show-ref --verify --quiet refs/remotes/origin/metrics; then + echo "๐Ÿ“‹ Checking out existing metrics branch..." + git checkout -b metrics origin/metrics || git checkout metrics + else + echo "๐Ÿ†• Creating new metrics branch..." + git checkout -b metrics + fi + + - name: Fetch clone data + env: + TOKEN: ${{ steps.generate_token.outputs.token }} + run: | + mkdir -p .metrics + # Fetch clone metrics (contains both daily breakdown and 14-day totals) + curl -s -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer $TOKEN" \ + https://api.github.com/repos/${{ github.repository }}/traffic/clones \ + > .metrics/clone_stats.json + + echo "Clone metrics:" + cat .metrics/clone_stats.json + + - name: Update daily metrics + run: | + # Process each day from the clones array + LAST_UPDATED=$(date -u +"%Y-%m-%d %H:%M:%S UTC") + + # Create daily CSV with header if it doesn't exist + if [ ! -f .metrics/daily_clone_metrics.csv ]; then + echo "date,total_clones,unique_cloners,last_updated" > .metrics/daily_clone_metrics.csv + fi + + echo "๐Ÿ“Š Processing daily metrics..." + jq -r '.clones[] | "\(.timestamp | split("T")[0]),\(.count),\(.uniques)"' .metrics/clone_stats.json | while IFS=',' read -r day_date count uniques; do + echo "Processing $day_date: $count clones, $uniques unique" + + # Check if this date already exists in the CSV + if grep -q "^$day_date" .metrics/daily_clone_metrics.csv; then + echo "๐Ÿ“ Updating existing entry for $day_date..." + # Update existing entry + awk -v date="$day_date" -v count="$count" -v uniques="$uniques" -v last_updated="$LAST_UPDATED" ' + BEGIN { FS=","; OFS="," } + /^[0-9]{4}-[0-9]{2}-[0-9]{2}/ && $1 == date { + print $1, count, uniques, last_updated; + updated=1; + next + } + { print } + ' .metrics/daily_clone_metrics.csv > .metrics/daily_clone_metrics_temp.csv + mv .metrics/daily_clone_metrics_temp.csv .metrics/daily_clone_metrics.csv + else + echo "โž• Adding new daily entry for $day_date..." + echo "$day_date,$count,$uniques,$LAST_UPDATED" >> .metrics/daily_clone_metrics.csv + fi + done + + echo "Daily metrics:" + tail -n 5 .metrics/daily_clone_metrics.csv + + - name: Update 14-day rolling metrics + run: | + # Process 14-day metrics + COUNT_14D=$(jq '.count' .metrics/clone_stats.json) + UNIQUES_14D=$(jq '.uniques' .metrics/clone_stats.json) + DATE_ONLY=$(date -u +"%Y-%m-%d") + LAST_UPDATED=$(date -u +"%Y-%m-%d %H:%M:%S UTC") + + echo "๐Ÿ“Š Processing 14-day metrics... for date: $DATE_ONLY" + echo "Processing values: $COUNT_14D clones, $UNIQUES_14D unique" + + # Create 14-day CSV with header if it doesn't exist + if [ ! -f .metrics/rolling_14d_clone_metrics.csv ]; then + echo "date,total_clones_14d,unique_cloners_14d,last_updated" > .metrics/rolling_14d_clone_metrics.csv + echo "๐Ÿ“„ Created new 14-day rolling CSV file" + fi + + # Check if today's date already exists in the 14-day CSV + if grep -q "^$DATE_ONLY" .metrics/rolling_14d_clone_metrics.csv; then + echo "๐Ÿ“ Updating existing 14-day rolling entry for $DATE_ONLY..." + # Update existing entry + awk -v date="$DATE_ONLY" -v count="$COUNT_14D" -v uniques="$UNIQUES_14D" -v last_updated="$LAST_UPDATED" ' + BEGIN { FS=","; OFS=","; updated=0 } + /^[0-9]{4}-[0-9]{2}-[0-9]{2}/ && $1 == date { + print $1, count, uniques, last_updated; + updated=1; + next + } + { print } + END { if (!updated) print date, count, uniques, last_updated } + ' .metrics/rolling_14d_clone_metrics.csv > .metrics/rolling_14d_clone_metrics_temp.csv + mv .metrics/rolling_14d_clone_metrics_temp.csv .metrics/rolling_14d_clone_metrics.csv + echo "โœ… Updated existing entry" + else + echo "โž• Adding new 14-day rolling entry for $DATE_ONLY..." + echo "$DATE_ONLY,$COUNT_14D,$UNIQUES_14D,$LAST_UPDATED" >> .metrics/rolling_14d_clone_metrics.csv + echo "โœ… Added new entry" + fi + + echo "14-day rolling metrics:" + tail -n 5 .metrics/rolling_14d_clone_metrics.csv + + - name: Commit and push results + env: + GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} + run: | + git config user.name "CloneMetricsBot[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + # Add both CSV files + git add .metrics/daily_clone_metrics.csv .metrics/rolling_14d_clone_metrics.csv + + # Check if there are changes to commit + if git diff --staged --quiet; then + echo "โ„น๏ธ No changes to commit - CSV data is up to date" + else + echo "๐Ÿ“ Committing changes..." + git commit -m "Automated update: repository clone metrics $(date)" + + echo "๐Ÿš€ Pushing to metrics branch..." + git push --force-with-lease origin metrics + fi diff --git a/.github/workflows/download-metrics.yml b/.github/workflows/download-metrics.yml new file mode 100644 index 0000000..ee473c3 --- /dev/null +++ b/.github/workflows/download-metrics.yml @@ -0,0 +1,109 @@ +name: Track Download Metrics + +on: + workflow_dispatch: + workflow_run: + workflows: ["Track View Metrics"] # exact name of PyPI workflow + types: [completed] + +jobs: + download-stats: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Fetch all history for proper branch operations + + - name: Generate GitHub App token + id: generate_token + uses: tibdex/github-app-token@v2.1.0 + with: + app_id: ${{ secrets.APP_ID }} + private_key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Switch to metrics branch + run: | + # Checkout or create metrics branch + if git show-ref --verify --quiet refs/remotes/origin/metrics; then + echo "๐Ÿ“‹ Checking out existing metrics branch..." + git checkout -b metrics origin/metrics || git checkout metrics + else + echo "๐Ÿ†• Creating new metrics branch..." + git checkout -b metrics + fi + + - name: Fetch download data + env: + TOKEN: ${{ steps.generate_token.outputs.token }} + run: | + mkdir -p .metrics + # Fetch download metrics from releases + curl -s -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer $TOKEN" \ + "https://api.github.com/repos/${{ github.repository }}/releases?per_page=100" \ + > .metrics/releases.json + + echo "Download metrics:" + jq '[.[] | {tag: .tag_name, date: .published_at, total_downloads: ([.assets[].download_count] | add), assets_count: (.assets | length)}]' .metrics/releases.json + + - name: Update total download metrics + run: | + # Process each release from the releases array + LAST_UPDATED=$(date -u +"%Y-%m-%d %H:%M:%S UTC") + + # Create total download CSV with header if it doesn't exist + if [ ! -f .metrics/total_download_metrics.csv ]; then + echo "date,release_tag,total_downloads,last_updated" > .metrics/total_download_metrics.csv + fi + + echo "๐Ÿ“Š Processing total download metrics..." + jq -r '.[] | "\(.published_at[0:10]),\(.tag_name),\([.assets[].download_count] | add)"' .metrics/releases.json | while IFS=',' read -r release_date tag_name total_downloads; do + echo "Processing $release_date: $tag_name - $total_downloads downloads" + + # Check if this release already exists in the CSV + if grep -q ",$tag_name," .metrics/total_download_metrics.csv; then + echo "๐Ÿ“ Updating existing entry for $tag_name..." + # Update existing entry + awk -v tag="$tag_name" -v downloads="$total_downloads" -v last_updated="$LAST_UPDATED" ' + BEGIN { FS=","; OFS="," } + $2 == tag { + print $1, $2, downloads, last_updated; + updated=1; + next + } + { print } + ' .metrics/total_download_metrics.csv > .metrics/total_download_metrics_temp.csv + mv .metrics/total_download_metrics_temp.csv .metrics/total_download_metrics.csv + else + echo "โž• Adding new download entry for $tag_name..." + echo "$release_date,$tag_name,$total_downloads,$LAST_UPDATED" >> .metrics/total_download_metrics.csv + fi + done + + echo "Total download metrics:" + tail -n 5 .metrics/total_download_metrics.csv + + - name: Commit and push results + env: + GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} + run: | + git config user.name "DownloadMetricsBot[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + # Add CSV file + git add .metrics/total_download_metrics.csv + + # Check if there are changes to commit + if git diff --staged --quiet; then + echo "โ„น๏ธ No changes to commit - CSV data is up to date" + else + echo "๐Ÿ“ Committing changes..." + git commit -m "Automated update: repository download metrics $(date)" + + echo "๐Ÿš€ Pushing to metrics branch..." + git push --force-with-lease origin metrics + fi diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml new file mode 100644 index 0000000..95cce2a --- /dev/null +++ b/.github/workflows/integration-tests.yml @@ -0,0 +1,68 @@ +name: Integration Tests + +on: + pull_request: + push: + branches: [develop, main] + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + integration: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + ros-distro: [melodic, noetic, humble, jazzy] + include: + - ros-distro: melodic + dockerfile: Dockerfile.ros1-melodic + container-name: integration-ros-melodic + - ros-distro: noetic + dockerfile: Dockerfile.ros1-noetic + container-name: integration-ros-noetic + - ros-distro: humble + dockerfile: Dockerfile.ros2-humble + container-name: integration-ros2-humble + - ros-distro: jazzy + dockerfile: Dockerfile.ros2-jazzy + container-name: integration-ros2-jazzy + + name: ${{ matrix.ros-distro }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Install dependencies + run: uv sync --extra dev + + - name: Start ROS container + run: | + ROS_DOCKERFILE=${{ matrix.dockerfile }} \ + ROS_CONTAINER_NAME=${{ matrix.container-name }} \ + docker compose -f tests/integration/docker-compose.yml up --build -d --wait + timeout-minutes: 10 + + - name: Detect ROS version + run: uv run python tests/integration/test_quick_detect.py + + - name: Run integration tests + run: | + uv run pytest tests/integration/ -v \ + --ros-distro ${{ matrix.ros-distro }} --skip-compose + + - name: Tear down + if: always() + run: | + ROS_DOCKERFILE=${{ matrix.dockerfile }} \ + ROS_CONTAINER_NAME=${{ matrix.container-name }} \ + docker compose -f tests/integration/docker-compose.yml down --volumes --remove-orphans diff --git a/.github/workflows/merge-into-branch.yml b/.github/workflows/merge-into-branch.yml new file mode 100644 index 0000000..ee9a227 --- /dev/null +++ b/.github/workflows/merge-into-branch.yml @@ -0,0 +1,154 @@ +name: Merge branch into target + +# Manual trigger only +on: + workflow_dispatch: + inputs: + source_branch: + description: "Branch to merge from (default: the branch this workflow is run on)" + type: string + required: false + default: "" + target_branch: + description: "Branch to merge into" + type: string + required: true + default: develop + delete_source_branch: + description: "Delete the source branch after a successful merge" + type: boolean + required: false + default: false + check_only: + description: "Verify the merge succeeds without pushing" + type: boolean + required: false + default: false + +permissions: + contents: write + pull-requests: write + +jobs: + merge-into-branch: + runs-on: ubuntu-latest + name: Merge '${{ inputs.source_branch || github.ref_name }}' into '${{ inputs.target_branch }}' + env: + SRC_BRANCH: ${{ inputs.source_branch || github.ref_name }} + TARGET_BRANCH: ${{ inputs.target_branch }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ inputs.target_branch }} + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + - name: Set up git user + run: | + git config user.name "RobotMCP Bot" + git config user.email "admin@robotmcp.ai" + + # Make sure we have the source branch and a common ancestor to merge. + git fetch origin "$SRC_BRANCH" + if ! git merge-base "$TARGET_BRANCH" "origin/$SRC_BRANCH"; then + echo "No common ancestor found for merging!" + exit 1 + fi + - name: Abort if conflict-resolution PR already open + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + SAFE_SRC="${SRC_BRANCH//\//_}" + SAFE_TARGET="${TARGET_BRANCH//\//_}" + EXISTING_PR=$(gh pr list \ + --base "$TARGET_BRANCH" \ + --state open \ + --json url,headRefName \ + --jq ".[] | select(.headRefName | startswith(\"${SAFE_SRC}-\") and endswith(\"/merge-into-${SAFE_TARGET}\")) | .url" \ + 2>/dev/null | head -n1 || true) + if [ -n "$EXISTING_PR" ]; then + echo "::error::An open conflict-resolution PR already exists for '$SRC_BRANCH' -> '$TARGET_BRANCH': $EXISTING_PR" + echo "::error::Resolve and merge that PR before re-running this workflow." + exit 1 + fi + - name: Attempt merge + id: merge + run: | + set +e + git merge \ + --no-ff \ + --no-edit \ + --commit \ + --message "Merged '$SRC_BRANCH' into '$TARGET_BRANCH'" \ + "origin/$SRC_BRANCH" + STATUS=$? + if [ $STATUS -ne 0 ]; then + echo "Merge conflicts detected; aborting in-runner merge." + git merge --abort || true + echo "conflict=true" >> "$GITHUB_OUTPUT" + else + echo "conflict=false" >> "$GITHUB_OUTPUT" + fi + exit 0 + - name: Push merged branch + if: ${{ steps.merge.outputs.conflict == 'false' && !inputs.check_only }} + run: git push origin "$TARGET_BRANCH" + - name: Delete source branch + if: ${{ inputs.delete_source_branch && steps.merge.outputs.conflict == 'false' && !inputs.check_only }} + run: git push origin --delete "$SRC_BRANCH" + - name: Create sync branch for conflict resolution + id: sync-branch + if: ${{ steps.merge.outputs.conflict == 'true' && !inputs.check_only }} + run: | + SAFE_SRC="${SRC_BRANCH//\//_}" + SAFE_TARGET="${TARGET_BRANCH//\//_}" + SHA_SHORT=$(git rev-parse --short "origin/$SRC_BRANCH") + SYNC_BRANCH="${SAFE_SRC}-${SHA_SHORT}/merge-into-${SAFE_TARGET}" + echo "Creating sync branch: $SYNC_BRANCH" + # If a stale sync branch exists from a previously closed-but-unmerged PR, drop it. + git push origin --delete "$SYNC_BRANCH" 2>/dev/null || true + git branch -f "$SYNC_BRANCH" "origin/$SRC_BRANCH" + git push origin "$SYNC_BRANCH" + echo "SYNC_BRANCH=$SYNC_BRANCH" >> "$GITHUB_OUTPUT" + - name: Open conflict-resolution PR + if: ${{ steps.merge.outputs.conflict == 'true' && !inputs.check_only }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SYNC_BRANCH: ${{ steps.sync-branch.outputs.SYNC_BRANCH }} + run: | + gh pr create \ + --base "$TARGET_BRANCH" \ + --head "$SYNC_BRANCH" \ + --title "Conflict-resolution: merge '$SRC_BRANCH' into '$TARGET_BRANCH'" \ + --draft \ + --body "Automated merge of \`$SRC_BRANCH\` into \`$TARGET_BRANCH\` failed due to conflicts. + + A temporary sync branch \`$SYNC_BRANCH\` was created off \`$SRC_BRANCH\` so conflicts can be resolved even when both branches are protected. + + **Do not merge this PR through the GitHub UI.** Squash-merging a sync branch rewrites its commits, which inflates the commit count of later merges into \`$TARGET_BRANCH\`. Instead, a maintainer should: + + 1. Check out the sync branch locally: + \`\`\` + git fetch origin + git checkout $SYNC_BRANCH + \`\`\` + + 2. Merge '$TARGET_BRANCH' into the sync branch and resolve conflicts: + \`\`\` + git merge origin/$TARGET_BRANCH + # resolve conflicts, then commit + \`\`\` + + 3. Push the resolved sync branch: + \`\`\` + git push origin $SYNC_BRANCH + \`\`\` + + 4. Re-run the 'Merge branch into target' workflow from this branch (\`$SYNC_BRANCH\`) with target branch '$TARGET_BRANCH'. + + 5. After the workflow completes, this PR will close automatically." + - name: Fail on conflict + if: ${{ steps.merge.outputs.conflict == 'true' }} + run: | + echo "::error::Merge of '$SRC_BRANCH' into '$TARGET_BRANCH' has conflicts." + exit 1 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..2ddf911 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,110 @@ +name: Publish (PyPI + MCP Registry) + +on: + workflow_dispatch: + push: + tags: + - 'v*' + +concurrency: + group: publish-${{ github.ref }} + cancel-in-progress: true + +jobs: + pypi: + name: Publish to PyPI + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + id-token: write # REQUIRED for PyPI Trusted Publishing (OIDC) + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Validate version matches tag + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + run: | + set -euo pipefail + + TAG_VERSION="${GITHUB_REF#refs/tags/v}" + PYPROJECT_VERSION=$(grep -oP '^version = "\K[^"]+' pyproject.toml) + SERVER_VERSION=$(grep -oP '"version":\s*"\K[^"]+' server.json | head -1) + SERVER_PKG_VERSION=$(grep -oP '"version":\s*"\K[^"]+' server.json | tail -1) + + ERRORS=0 + [ "$PYPROJECT_VERSION" != "$TAG_VERSION" ] && echo "pyproject.toml: $PYPROJECT_VERSION != $TAG_VERSION" && ERRORS=1 + [ "$SERVER_VERSION" != "$TAG_VERSION" ] && echo "server.json: $SERVER_VERSION != $TAG_VERSION" && ERRORS=1 + [ "$SERVER_PKG_VERSION" != "$TAG_VERSION" ] && echo "server.json package: $SERVER_PKG_VERSION != $TAG_VERSION" && ERRORS=1 + + if [ $ERRORS -eq 1 ]; then + echo "Please update all version fields to $TAG_VERSION before creating the tag." + exit 1 + fi + + echo "All versions match: $TAG_VERSION" + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install uv + run: pipx install uv + + - name: Sync dependencies + run: uv sync --extra dev + + - name: Run ruff lint + run: uvx ruff check . + + - name: Run ruff format check + run: uvx ruff format --check . + + - name: Run tests + run: | + if [ -d tests ]; then + uv run pytest -q -m "not slow and not integration" || [ $? -eq 5 ] # exit 5 = no tests collected + else + echo "No tests/ directory; skipping." + fi + + - name: Build package + run: uv build + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist + + registry: + name: Publish to MCP Registry + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: pypi + permissions: + contents: read + id-token: write # REQUIRED for MCP Registry GitHub OIDC login + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Wait for PyPI metadata propagation + run: sleep 20 + + - name: Install MCP Publisher + run: | + set -euo pipefail + OS=$(uname -s | tr '[:upper:]' '[:lower:]') + ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') + echo "Get the latest release version" + LATEST_VERSION=$(curl -s https://api.github.com/repos/modelcontextprotocol/registry/releases/latest | jq -r '.tag_name') + echo "Installing MCP Publisher version: $LATEST_VERSION" + curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_${OS}_${ARCH}.tar.gz" \ + | tar xz mcp-publisher + + - name: Login to MCP Registry + run: ./mcp-publisher login github-oidc + + - name: Publish to MCP Registry + run: ./mcp-publisher publish diff --git a/.github/workflows/publish_mcp.yml b/.github/workflows/publish_mcp.yml new file mode 100644 index 0000000..df202c9 --- /dev/null +++ b/.github/workflows/publish_mcp.yml @@ -0,0 +1,37 @@ +name: Publish MCP Registry + +on: + workflow_dispatch: + +concurrency: + group: publish-mcp-${{ github.ref }} + cancel-in-progress: true + +jobs: + mcp-registry: + name: Publish to MCP Registry + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + id-token: write # REQUIRED for MCP Registry GitHub OIDC login + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install MCP Publisher + run: | + set -euo pipefail + OS=$(uname -s | tr '[:upper:]' '[:lower:]') + ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') + echo "Get the latest release version" + LATEST_VERSION=$(curl -s https://api.github.com/repos/modelcontextprotocol/registry/releases/latest | jq -r '.tag_name') + echo "Installing MCP Publisher version: $LATEST_VERSION" + curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_${OS}_${ARCH}.tar.gz" \ + | tar xz mcp-publisher + + - name: Login to MCP Registry + run: ./mcp-publisher login github-oidc + + - name: Publish to MCP Registry + run: ./mcp-publisher publish \ No newline at end of file diff --git a/.github/workflows/publish_pypi.yml b/.github/workflows/publish_pypi.yml new file mode 100644 index 0000000..b9e64e3 --- /dev/null +++ b/.github/workflows/publish_pypi.yml @@ -0,0 +1,53 @@ +name: Publish PyPI + +on: + workflow_dispatch: + +concurrency: + group: publish-pypi-${{ github.ref }} + cancel-in-progress: true + +jobs: + pypi: + name: Publish PyPI + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + id-token: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install uv + run: pipx install uv + + - name: Sync dependencies + run: uv sync --extra dev + + - name: Run ruff lint + run: uvx ruff check . + + - name: Run ruff format check + run: uvx ruff format --check . + + - name: Run tests + run: | + if [ -d tests ]; then + uv run pytest -q -m "not slow and not integration" || [ $? -eq 5 ] # exit 5 = no tests collected + else + echo "No tests/ directory; skipping." + fi + + - name: Build package + run: uv build + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist \ No newline at end of file diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml new file mode 100644 index 0000000..35df8f4 --- /dev/null +++ b/.github/workflows/ruff.yml @@ -0,0 +1,27 @@ +name: Ruff Lint & Format + +on: + pull_request: + push: + branches: [develop, main] + +jobs: + ruff: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install Ruff + run: pip install ruff + + - name: Run Ruff check (lint) + run: ruff check . + + - name: Run Ruff format (verify formatting) + run: ruff format --check . diff --git a/.github/workflows/sync-main-to-develop.yml b/.github/workflows/sync-main-to-develop.yml new file mode 100644 index 0000000..1602df9 --- /dev/null +++ b/.github/workflows/sync-main-to-develop.yml @@ -0,0 +1,115 @@ +name: Sync main to develop + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + check: + runs-on: ubuntu-latest + name: Check if sync is needed + outputs: + skip: ${{ steps.check-sync.outputs.SKIP }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + - name: Check if sync is needed + id: check-sync + run: | + # Use merge-tree to check if merging main into develop would produce + # any content changes, without touching the working tree. This correctly + # handles cherry-picks where the SHAs differ but the content is identical. + if MERGE_TREE=$(git merge-tree --write-tree origin/develop origin/main 2>&1); then + DEVELOP_TREE=$(git rev-parse origin/develop^{tree}) + echo "Merge result tree: $MERGE_TREE" + echo "Develop tree: $DEVELOP_TREE" + if [ "$MERGE_TREE" = "$DEVELOP_TREE" ]; then + echo "Merge would not change develop. Skipping sync." + echo "SKIP=true" >> "$GITHUB_OUTPUT" + else + echo "Merge would introduce changes. Sync needed." + echo "SKIP=false" >> "$GITHUB_OUTPUT" + fi + else + echo "Merge would have conflicts. Sync needed." + echo "SKIP=false" >> "$GITHUB_OUTPUT" + fi + + sync: + needs: check + if: needs.check.outputs.skip != 'true' + runs-on: ubuntu-latest + name: Sync main to develop + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + - name: Set up git user + run: | + git config user.name "RobotMCP Bot" + git config user.email "admin@robotmcp.ai" + - name: Create syncing branch off of main + id: create-sync-branch + run: | + SYNC_BRANCH="main-$(git rev-parse --short HEAD)/sync" + echo "Creating syncing branch: $SYNC_BRANCH" + git switch --create "$SYNC_BRANCH" + git push origin "$SYNC_BRANCH" + echo "SYNC_BRANCH=$SYNC_BRANCH" >> "$GITHUB_OUTPUT" + - name: Create syncing PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SYNC_BRANCH: ${{ steps.create-sync-branch.outputs.SYNC_BRANCH }} + run: | + gh pr create \ + --base develop \ + --head "$SYNC_BRANCH" \ + --title "chore: sync main to develop" \ + --draft \ + --body "Auto-syncing 'main' to 'develop' + + If this PR does not merge automatically, there are conflicts between 'main' and 'develop'. + + **Do not merge this PR through GitHub.** A maintainer should: + + 1. Pull this temporary syncing branch locally: + \`\`\` + git fetch origin + git checkout $SYNC_BRANCH + \`\`\` + + 2. Merge 'develop' into this branch and resolve conflicts: + \`\`\` + git merge origin/develop + # resolve any conflicts, then commit + \`\`\` + + 3. Push the resolved branch: + \`\`\` + git push origin $SYNC_BRANCH + \`\`\` + + 4. Run the 'Merge branch into target' workflow from this branch with target branch 'develop' + + 5. After the workflow completes, this PR will close automatically" + - name: Merge and push to develop + run: | + git switch develop + git merge --no-ff --no-edit --commit --message "chore: sync main to develop" "${{ steps.create-sync-branch.outputs.SYNC_BRANCH }}" + git push origin develop + - name: Cleanup sync branch + continue-on-error: true + env: + SYNC_BRANCH: ${{ steps.create-sync-branch.outputs.SYNC_BRANCH }} + run: git push origin :"$SYNC_BRANCH" diff --git a/.github/workflows/view-metrics.yml b/.github/workflows/view-metrics.yml new file mode 100644 index 0000000..c0905ec --- /dev/null +++ b/.github/workflows/view-metrics.yml @@ -0,0 +1,151 @@ +name: Track View Metrics + +on: + workflow_dispatch: + workflow_run: + workflows: ["Track Clone Metrics"] # exact name of PyPI workflow + types: [completed] + +jobs: + view-stats: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Fetch all history for proper branch operations + + - name: Generate GitHub App token + id: generate_token + uses: tibdex/github-app-token@v2.1.0 + with: + app_id: ${{ secrets.APP_ID }} + private_key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Switch to metrics branch + run: | + # Checkout or create metrics branch + if git show-ref --verify --quiet refs/remotes/origin/metrics; then + echo "๐Ÿ“‹ Checking out existing metrics branch..." + git checkout -b metrics origin/metrics || git checkout metrics + else + echo "๐Ÿ†• Creating new metrics branch..." + git checkout -b metrics + fi + + - name: Fetch view data + env: + TOKEN: ${{ steps.generate_token.outputs.token }} + run: | + mkdir -p .metrics + # Fetch view metrics (contains both daily breakdown and 14-day totals) + curl -s -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer $TOKEN" \ + https://api.github.com/repos/${{ github.repository }}/traffic/views \ + > .metrics/view_stats.json + + echo "View metrics:" + cat .metrics/view_stats.json + + - name: Update daily metrics + run: | + # Process each day from the views array + LAST_UPDATED=$(date -u +"%Y-%m-%d %H:%M:%S UTC") + + # Create daily CSV with header if it doesn't exist + if [ ! -f .metrics/daily_view_metrics.csv ]; then + echo "date,total_views,unique_visitors,last_updated" > .metrics/daily_view_metrics.csv + fi + + echo "๐Ÿ“Š Processing daily metrics..." + jq -r '.views[] | "\(.timestamp | split("T")[0]),\(.count),\(.uniques)"' .metrics/view_stats.json | while IFS=',' read -r day_date count uniques; do + echo "Processing $day_date: $count views, $uniques unique" + + # Check if this date already exists in the CSV + if grep -q "^$day_date" .metrics/daily_view_metrics.csv; then + echo "๐Ÿ“ Updating existing entry for $day_date..." + # Update existing entry + awk -v date="$day_date" -v count="$count" -v uniques="$uniques" -v last_updated="$LAST_UPDATED" ' + BEGIN { FS=","; OFS="," } + /^[0-9]{4}-[0-9]{2}-[0-9]{2}/ && $1 == date { + print $1, count, uniques, last_updated; + updated=1; + next + } + { print } + ' .metrics/daily_view_metrics.csv > .metrics/daily_view_metrics_temp.csv + mv .metrics/daily_view_metrics_temp.csv .metrics/daily_view_metrics.csv + else + echo "โž• Adding new daily entry for $day_date..." + echo "$day_date,$count,$uniques,$LAST_UPDATED" >> .metrics/daily_view_metrics.csv + fi + done + + echo "Daily metrics:" + tail -n 5 .metrics/daily_view_metrics.csv + + - name: Update 14-day rolling metrics + run: | + # Process 14-day metrics + COUNT_14D=$(jq '.count' .metrics/view_stats.json) + UNIQUES_14D=$(jq '.uniques' .metrics/view_stats.json) + DATE_ONLY=$(date -u +"%Y-%m-%d") + LAST_UPDATED=$(date -u +"%Y-%m-%d %H:%M:%S UTC") + + echo "๐Ÿ“Š Processing 14-day metrics... for date: $DATE_ONLY" + echo "Processing values: $COUNT_14D views, $UNIQUES_14D unique" + + # Create 14-day CSV with header if it doesn't exist + if [ ! -f .metrics/rolling_14d_view_metrics.csv ]; then + echo "date,total_views_14d,unique_visitors_14d,last_updated" > .metrics/rolling_14d_view_metrics.csv + echo "๐Ÿ“„ Created new 14-day rolling CSV file" + fi + + # Check if today's date already exists in the 14-day CSV + if grep -q "^$DATE_ONLY" .metrics/rolling_14d_view_metrics.csv; then + echo "๐Ÿ“ Updating existing 14-day rolling entry for $DATE_ONLY..." + # Update existing entry + awk -v date="$DATE_ONLY" -v count="$COUNT_14D" -v uniques="$UNIQUES_14D" -v last_updated="$LAST_UPDATED" ' + BEGIN { FS=","; OFS=","; updated=0 } + /^[0-9]{4}-[0-9]{2}-[0-9]{2}/ && $1 == date { + print $1, count, uniques, last_updated; + updated=1; + next + } + { print } + END { if (!updated) print date, count, uniques, last_updated } + ' .metrics/rolling_14d_view_metrics.csv > .metrics/rolling_14d_view_metrics_temp.csv + mv .metrics/rolling_14d_view_metrics_temp.csv .metrics/rolling_14d_view_metrics.csv + echo "โœ… Updated existing entry" + else + echo "โž• Adding new 14-day rolling entry for $DATE_ONLY..." + echo "$DATE_ONLY,$COUNT_14D,$UNIQUES_14D,$LAST_UPDATED" >> .metrics/rolling_14d_view_metrics.csv + echo "โœ… Added new entry" + fi + + echo "14-day rolling metrics:" + tail -n 5 .metrics/rolling_14d_view_metrics.csv + + - name: Commit and push results + env: + GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} + run: | + git config user.name "ViewMetricsBot[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + # Add both CSV files + git add .metrics/daily_view_metrics.csv .metrics/rolling_14d_view_metrics.csv + + # Check if there are changes to commit + if git diff --staged --quiet; then + echo "โ„น๏ธ No changes to commit - CSV data is up to date" + else + echo "๐Ÿ“ Committing changes..." + git commit -m "Automated update: repository view metrics $(date)" + + echo "๐Ÿš€ Pushing to metrics branch..." + git push --force-with-lease origin metrics + fi \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..122e25b --- /dev/null +++ b/.gitignore @@ -0,0 +1,184 @@ +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json + +# DS Store +.DS_Store + +# .vscode +.vscode/ +.vs/ + +# camera +camera/* + +# superpowers plans +docs/superpowers/ \ No newline at end of file diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..c8cfe39 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4144f41 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [2025] [Contoro Inc.] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..6ea98cd --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +include server.json +recursive-include robot_specifications *.yaml diff --git a/README.md b/README.md new file mode 100644 index 0000000..53a3095 --- /dev/null +++ b/README.md @@ -0,0 +1,105 @@ +# ROS MCP Server ๐Ÿง โ‡„๐Ÿค– + +![Static Badge](https://img.shields.io/badge/ROS-Available-green) +![Static Badge](https://img.shields.io/badge/ROS2-Available-green) +![Static Badge](https://img.shields.io/badge/License-Apache%202.0-blue) +![Python](https://img.shields.io/badge/python-3.10%2B-blue) +![pip](https://img.shields.io/badge/pip-23.0%2B-blue) +![Dev Container](https://img.shields.io/badge/Dev-Container%20Ready-blue) +![GitHub Repo stars](https://img.shields.io/github/stars/robotmcp/ros-mcp-server?style=social) +![GitHub last commit](https://img.shields.io/github/last-commit/robotmcp/ros-mcp-server) + + + +

+ +

+ +ROS-MCP-Server connects large language models (such as Claude, GPT, and Gemini) to robots, enabling bidirectional communication with no changes to existing robot source code. + +### Why ROS-MCP? + +- **No robot source code changes** โ†’ just add the `rosbridge` node to your existing ROS setup. +- **True two-way communication** โ†’ LLMs can both *control* robots and *observe* everything happening on the Robot. +- **Full context** โ†’ publish & subscribe to topics, call services & actions, set parameters, read sensor data, and monitor robot state in real time. +- **Deep ROS understanding** โ†’ guides the LLM to discover available topics, services, actions, and their types (including custom ones) โ€” enabling it to use them with the right syntax without manual configuration. +- **Works with any MCP client** โ†’ built on the open [MCP standard](https://modelcontextprotocol.io/), supporting Claude Code, Codex CLI, Gemini CLI, Claude Desktop, ChatGPT, Cursor, and more. +- **Works across ROS versions** โ†’ compatible across ROS 2 (Jazzy, Humble, and others) and ROS 1 distros. + +--- +## ๐ŸŽฅ Examples in Action + +

+ + ROS MCP demos + +

+ +--- +๐Ÿญ **Example - AI Agent diagnosis of Industrial Robot End Effector** ([Video](https://youtu.be/EhZNFULz9P4)) +- The MCP server connects Claude to a production industrial robot, with only the technician manuals as reference. +- Claude discovers the robot's custom topic and service types and their syntax on its own. +- From a single prompt to test the gripper, it reads the manuals, runs its own tests, finds an anomaly, and reports the root cause. + +

+ + Testing and debugging an industrial robot + +

+ +--- +๐Ÿค– **Example - Controlling "Wilson" with natural language** ([video](https://www.traceglarue.com/wilson)) +From a single prompt โ€” *"Grab a Coke from the fridge & go to the living room."* โ€” Google Gemini uses the MCP server to navigate and manipulate autonomously. Built on ROS 2 with Nav2 (SLAM) for mapping and navigation, and MoveIt to command the manipulator. + +

+ + Wilson robot controlled with natural language + +

+ +--- +๐Ÿ• **Example - Controlling Unitree Go2 in NVIDIA Isaac Sim** ([video](https://www.youtube.com/watch?v=9StFx4lnvmc)) +The MCP server connects Claude to a simulated Unitree Go2 quadruped in NVIDIA Isaac Sim, interpreting natural language commands to navigate and control the robot. + +

+ + Controlling Unitree Go2 in NVIDIA Isaac Sim + +

+ + +--- + +## ๐Ÿ›  Getting Started + +Follow the [installation guide](docs/install/installation.md) to get started. + +ROS-MCP works with Claude Code, Codex CLI, Gemini CLI, Claude Desktop, ChatGPT, Cursor, or any MCP-compatible client. + +

+ +

+ +--- + +## ๐Ÿ“š More Examples & Tutorials + +Browse our [examples](examples) to see the server in action. +We welcome community PRs with new examples and integrations! + +--- + +## ๐Ÿค Contributing + +We love contributions of all kinds: +- Bug fixes and documentation updates +- New features (e.g., Action support, permissions) +- Additional examples and tutorials + +Check out the [contributing guidelines](docs/contributing.md) and see issues tagged **good first issue** to get started. + +--- + +## ๐Ÿ“œ License + +This project is licensed under the [Apache License 2.0](LICENSE). diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..6c5dec3 --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub ๆฅๆบ่ฏดๆ˜Ž + +- ๅŽŸๅง‹้กน็›ฎ๏ผš`robotmcp/ros-mcp-server` +- ๅŽŸๅง‹ไป“ๅบ“๏ผšhttps://github.com/robotmcp/ros-mcp-server +- ๅฏผๅ…ฅๆ–นๅผ๏ผšไธŠๆธธ้ป˜่ฎคๅˆ†ๆ”ฏ็š„ๆœ€ๆ–ฐๅฟซ็…ง +- ๅŽŸไฝœ่€…ใ€็‰ˆๆƒๅ’Œ่ฎธๅฏ่ฏไฟกๆฏไปฅๅŽŸๅง‹ไป“ๅบ“ๅŠๆœฌไป“ๅบ“ LICENSE ไธบๅ‡† +- ๆœฌๆ–‡ไปถไป…็”จไบŽ่ฎฐๅฝ•ๆฅๆบ๏ผŒไธไปฃ่กจ WeHub ๆ˜ฏๅŽŸ้กน็›ฎไฝœ่€… diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..5e3e631 --- /dev/null +++ b/config/__init__.py @@ -0,0 +1 @@ +# Config package for ROS MCP Server diff --git a/config/mcp.json b/config/mcp.json new file mode 100644 index 0000000..a4d4151 --- /dev/null +++ b/config/mcp.json @@ -0,0 +1,33 @@ +{ + "mcpServers": { + "ros-mcp-server-http": { + "name": "ROS-MCP Server (http)", + "transport": "http", + "url": "http://127.0.0.1:9000/mcp" + }, + "ros-mcp-server-stdio-linux": { + "name": "ROS-MCP Server (stdio)", + "transport": "stdio", + "command": "uv", + "args": [ + "--directory", + "/home//ros-mcp-server", + "run", + "server.py" + ] + }, + "ros-mcp-server-stdio-wsl": { + "name": "ROS-MCP Server (stdio)", + "transport": "stdio", + "command": "wsl", + "args": [ + "-d", "Ubuntu", + "/home//.local/bin/uv", + "--directory", + "/home//ros-mcp-server", + "run", + "server.py" + ] + } + } +} diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..4e1bb6d --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,304 @@ +# ROS MCP Server Architecture + +This document describes the architecture of the ROS MCP Server, including its components, organization, and design patterns. + +## Overview + +The ROS MCP Server is a Model Context Protocol (MCP) server that provides tools, resources, and prompts for interacting with ROS (Robot Operating System) robots via the rosbridge WebSocket interface. The server is built using FastMCP and follows a modular, category-based architecture. + +## Architecture Principles + +1. **Modular Design**: Tools, resources, and prompts are organized by category into separate modules +2. **Separation of Concerns**: Clear boundaries between tools, resources, prompts, and utilities +3. **Reusability**: Shared utilities and WebSocket manager for consistent ROS communication +4. **Extensibility**: Easy to add new tools, resources, or prompts by following established patterns +5. **Library-First**: Designed to be importable as a library for integration into other projects + +## Directory Structure + +``` +ros-mcp-server/ +โ”œโ”€โ”€ ros_mcp/ # Main package +โ”‚ โ”œโ”€โ”€ __init__.py # Package initialization +โ”‚ โ”œโ”€โ”€ main.py # MCP server instance and entry point +โ”‚ โ”‚ +โ”‚ โ”œโ”€โ”€ tools/ # Tool implementations (31 tools) +โ”‚ โ”‚ โ”œโ”€โ”€ __init__.py # Main registration function (public API) +โ”‚ โ”‚ โ”œโ”€โ”€ actions.py # Action tools (7 tools) +โ”‚ โ”‚ โ”œโ”€โ”€ connection.py # Connection tools (2 tools) +โ”‚ โ”‚ โ”œโ”€โ”€ images.py # Image analysis tools (1 tool + helpers) +โ”‚ โ”‚ โ”œโ”€โ”€ nodes.py # Node tools (3 tools) +โ”‚ โ”‚ โ”œโ”€โ”€ parameters.py # Parameter tools (7 tools) +โ”‚ โ”‚ โ”œโ”€โ”€ robot_config.py # Robot configuration tools (3 tools) +โ”‚ โ”‚ โ”œโ”€โ”€ services.py # Service tools (6 tools) +โ”‚ โ”‚ โ””โ”€โ”€ topics.py # Topic tools (10 tools) +โ”‚ โ”‚ +โ”‚ โ”œโ”€โ”€ resources/ # Resource implementations +โ”‚ โ”‚ โ”œโ”€โ”€ __init__.py # Resource registration function +โ”‚ โ”‚ โ”œโ”€โ”€ robot_specs.py # Robot specification resources +โ”‚ โ”‚ โ””โ”€โ”€ ros_metadata.py # ROS metadata resources (5 resources) +โ”‚ โ”‚ +โ”‚ โ”œโ”€โ”€ prompts/ # Prompt templates +โ”‚ โ”‚ โ”œโ”€โ”€ __init__.py # Prompt registration function +โ”‚ โ”‚ โ”œโ”€โ”€ test_actions_tools.py +โ”‚ โ”‚ โ”œโ”€โ”€ test_connection_tools.py +โ”‚ โ”‚ โ”œโ”€โ”€ test_nodes_tools.py +โ”‚ โ”‚ โ”œโ”€โ”€ test_parameters_tools.py +โ”‚ โ”‚ โ”œโ”€โ”€ test_server_tools.py +โ”‚ โ”‚ โ”œโ”€โ”€ test_services_tools.py +โ”‚ โ”‚ โ””โ”€โ”€ test_topics_tools.py +โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€ utils/ # Utility modules +โ”‚ โ”œโ”€โ”€ __init__.py +โ”‚ โ”œโ”€โ”€ config_utils.py # Robot configuration utilities +โ”‚ โ”œโ”€โ”€ network_utils.py # Network connectivity utilities +โ”‚ โ””โ”€โ”€ websocket.py # WebSocket manager for ROS communication +โ”‚ +โ”œโ”€โ”€ server.py # Entry point script +โ”œโ”€โ”€ robot_specifications/ # Robot specification YAML files +โ””โ”€โ”€ docs/ # Documentation +``` + +## Core Components + +### 1. Main Entry Point (`ros_mcp/main.py`) + +The main entry point initializes the MCP server and registers all components: + +```python +# Initialize MCP server +mcp = FastMCP("ros-mcp-server") + +# Initialize WebSocket manager +ws_manager = WebSocketManager(ROSBRIDGE_IP, ROSBRIDGE_PORT, default_timeout=5.0) + +# Register all components +register_all_tools(mcp, ws_manager, rosbridge_ip=ROSBRIDGE_IP, rosbridge_port=ROSBRIDGE_PORT) +register_all_resources(mcp, ws_manager) +register_all_prompts(mcp) +``` + +### 2. Tools (`ros_mcp/tools/`) + +Tools are the primary interface for interacting with ROS systems. They are organized by category and follow a consistent pattern. + +#### Tool Categories: Total 31 tools + +| Category | File | Count | Description | +|----------|------|-------|-------------| +| **Connection** | `connection.py` | 2 | Robot connection and connectivity testing | +| **Robot Config** | `robot_config.py` | 3 | Robot specification and ROS version detection | +| **Topics** | `topics.py` | 8 | Topic discovery, subscription, and publishing | +| **Services** | `services.py` | 4 | Service discovery and calling | +| **Nodes** | `nodes.py` | 2 | Node discovery and inspection | +| **Parameters** | `parameters.py` | 6 | Parameter management (ROS 2 only) | +| **Actions** | `actions.py` | 5 | Action discovery and execution (ROS 2 only) | +| **Images** | `images.py` | 1 | Image analysis and processing | + + +#### Tool Template + +All tools follow a consistent pattern with decorator, description, and comprehensive docstring: + +```python +def register_category_tools(mcp: FastMCP, ws_manager: WebSocketManager) -> None: + """Register all category-related tools.""" + + @mcp.tool( + description=( + "Brief description of what the tool does.\n" + "Example:\n" + "tool_name(param1='value1', param2=123)" + ) + ) + def tool_name(param1: str, param2: int = 0) -> dict: + """ + Comprehensive description of the tool's functionality. + + Args: + param1 (str): Description of parameter 1 (e.g., 'example value') + param2 (int): Description of parameter 2 (e.g., 123). Default is 0. + + Returns: + dict: Contains result data with specific fields, + or an error message if operation fails. + """ + # Implementation logic here + # Use ws_manager for ROS communication + with ws_manager: + # Tool implementation + pass + + return {"result": "data"} +``` + +**Key Components:** + +1. **Decorator** (`@mcp.tool`): + - Contains `description` parameter with brief explanation + - Includes usage examples when helpful + - Description is visible to LLMs for tool selection + +2. **Function Docstring**: + - Comprehensive description of functionality + - **Args section**: Documents all parameters with types, descriptions, and examples + - **Returns section**: Documents return value structure and possible error cases + - Docstring is for developer reference and IDE tooltips + +3. **Implementation**: + - Inline implementation + - Uses `ws_manager` context manager for ROS communication when needed + - Returns structured dictionaries with consistent error handling + +#### Public API + +The main registration function in `tools/__init__.py`: + +```python +def register_all_tools( + mcp: FastMCP, + ws_manager: WebSocketManager, + rosbridge_ip: str = "127.0.0.1", + rosbridge_port: int = 9090, +) -> None: + """Register all ROS MCP tools with the provided FastMCP instance.""" + register_action_tools(mcp, ws_manager) + register_connection_tools(mcp, ws_manager, rosbridge_ip, rosbridge_port) + # ... other categories +``` + +### 3. Resources (`ros_mcp/resources/`) + +Resources provide comprehensive system information in JSON format. They are accessed via URIs and return structured data. + +#### Resource Types + +**ROS Metadata Resources:** +- `ros-mcp://ros-metadata/all` - Complete system overview +- `ros-mcp://ros-metadata/topics/all` - All topics with details +- `ros-mcp://ros-metadata/services/all` - All services with details +- `ros-mcp://ros-metadata/nodes/all` - All nodes with details +- `ros-mcp://ros-metadata/actions/all` - All actions with details (ROS 2 only) + +**Robot Specification Resources:** +- `ros-mcp://robot-specs/get_verified_robots_list` - List of available robot specifications + +#### Public API + +The main registration function is found in `resources/__init__.py`: + +```python +def register_all_resources( + mcp: FastMCP, + ws_manager: WebSocketManager, +) -> None: + """Register all resources with the MCP server instance.""" + register_robot_spec_resources(mcp) + register_ros_metadata_resources(mcp, ws_manager) +``` + + + + +### 4. Prompts (`ros_mcp/prompts/`) + +Prompts are interactive guides that help users test and understand the ROS MCP Server tools. + +#### Prompt Categories + +- `test-server-tools` - High-level overview +- `test-connection-tools` - Connection testing +- `test-topics-tools` - Topic tools testing +- `test-services-tools` - Service tools testing +- `test-nodes-tools` - Node tools testing +- `test-parameters-tools` - Parameter tools testing (ROS 2) +- `test-actions-tools` - Action tools testing (ROS 2) + + +### 5. Utilities (`ros_mcp/utils/`) + +Utilities provide shared functionality used across tools and resources. + +#### Utility Modules + +**`websocket.py` - WebSocket Manager** +- Manages WebSocket connections to rosbridge +- Provides request/response interface for ROS communication +- Handles connection lifecycle and error handling +- Thread-safe context manager for connection management + +**`network_utils.py` - Network Utilities** +- `ping_ip_and_port()` - Test network connectivity +- Platform-specific ping implementation +- Port availability checking + +**`config_utils.py` - Configuration Utilities** +- `load_robot_config()` - Load robot specification YAML files +- `get_verified_robot_spec_util()` - Parse and validate robot configs +- `get_verified_robots_list_util()` - List available robot specifications + +## Extension Points + +### Adding a New Tool + +1. Create implementation function in appropriate category file +2. Create tool wrapper with `@mcp.tool` decorator +3. Register in category's `register_*_tools()` function +4. Tool is automatically available after server restart + +### Adding a New Resource + +1. Create resource function in `resources/ros_metadata.py` or `resources/robot_specs.py` +2. Use `@mcp.resource` decorator with URI +3. Add to `register_all_resources()` in `resources/__init__.py` +4. Resource is automatically available after server restart + +### Adding a New Prompt + +1. Create prompt function in `prompts/test_*.py` +2. Use `@mcp.prompt` decorator with name +3. Add to `register_all_prompts()` in `prompts/__init__.py` +4. Prompt is automatically available after server restart + +## Integration + +The ROS MCP Server is designed to be importable as a library: + +```python +from ros_mcp.tools import register_all_tools +from ros_mcp.resources import register_all_resources +from ros_mcp.prompts import register_all_prompts +from ros_mcp.utils.websocket import WebSocketManager + +# In your MCP server +mcp = FastMCP("your-server") +ws_manager = WebSocketManager("127.0.0.1", 9090) + +register_all_tools(mcp, ws_manager, rosbridge_ip="127.0.0.1", rosbridge_port=9090) +register_all_resources(mcp, ws_manager) +register_all_prompts(mcp) +``` + +## Dependencies + +### Core Dependencies +- **FastMCP**: MCP server framework +- **websocket-client**: WebSocket communication +- **opencv-python**: Image processing +- **numpy**: Numerical operations +- **PyYAML**: Robot configuration parsing + +### ROS Dependencies +- **rosbridge_server**: ROS WebSocket bridge (external, must be running) + +## Testing + +See `docs/testing.md` for detailed testing instructions. + +## Related Documentation + +- **Testing Guide**: `docs/testing.md` - How to test the server +- **Restructuring Plan**: `docs/restructuring_plan.md` - Migration history +- **Launch System**: `docs/launch_system.md` - ROS launch guide +- **Installation**: `docs/install/installation.md` - Setup instructions + diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..308dd47 --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,175 @@ +# Contributing to ROS-MCP-Server + +--- + +## ๐Ÿ“Œ Ways to Contribute + +- **Report issues**: Bug reports, documentation gaps, or feature requests. +- **Improve documentation**: Clarify installation steps, add tutorials, or share demos. +- **Submit code**: Bug fixes, new features, refactors, or tests. +- **Share use cases**: Show how you use ROS-MCP with your robot. +- **Community support**: Answer GitHub Discussions or ROS Discourse questions. + +--- + +## ๐Ÿ”€ How to Pull-request + +1. **Fork the repository:** Click the "Fork" button in the top right corner of the GitHub page. + +2. **Create a new branch:** Create a separate branch for your changes to keep them isolated from the main project. + + ```bash + git checkout -b "your_branch_name" + ``` + +3. **Make your changes:** Edit files with your additions or corrections. Please follow the existing format and style guidelines. When adding a new function, make sure to include: + + * The function name and format. + * A brief description of the function's functionality. + * Any parameters the function takes. + * The return type of the function. + +4. **Commit your changes:** Commit your changes with a clear and concise message explaining what you've done. + + ```bash + git commit -m "your_commit_message" + ``` + +5. **Push your branch:** Push your branch to your forked repository. + + ```bash + git push origin "your_branch_name" + ``` + +6. **Create a pull request:** Navigate to the original repository and click the "New pull request" button. Select your fork and the branch you pushed. **Important:** Target the `develop` branch (not `main`) for your pull request. Provide a clear title and description of your changes. + +7. **Review and merge:** Your pull request will be reviewed by the maintainers. They may suggest changes or ask for clarification. Once approved, your changes will be merged into the main repository. + +Thank you for contributing! + +--- + +## Style & CI +We use **Ruff** for both linting and formatting. CI requires: +- `ruff format --check .` +- `ruff check .` + +**Local setup** +- Option A (recommended): `pre-commit install` (auto-fixes on commit) +- Option B (manual): `ruff format . && ruff check --fix .` + +**Using Black locally?** +- That's fine. Please align with: + - `line-length = 100`, target `py310` + - Avoid `--preview`, `--skip-string-normalization`, `--skip-magic-trailing-comma` +- CI uses Ruff as the final arbiter; run `ruff format .` before pushing. + +--- + +## Optional: Using Devcontainer + +The devcontainer provides a stable testing platform with ROS2 humble pre-installed as well as an environment to test the MCP server in http transport. (stdio transport is not compatible with the devcontainer) + +1. Install [VSCode](https://code.visualstudio.com/) and the **Remote - Containers** extension. +2. Open the `ros-mcp-server` repository in VSCode. +3. When prompted, **reopen in container**. + - The container includes ROS2 Humble, Python 3.10+, `ruff`, `pre-commit`, `uv`, and `git`. + - The repository is mounted at `/root/workspace`. + - **Note for GUI apps** (`turtlesim`, `rviz`, `Gazebo`): + Ensure the container can access your host X server by running the following command once on the host: + +
+ Ubuntu host + + ```bash + sudo apt install x11-xserver-utils # if xhost is not installed + xhost +local:root # allow container user access + ``` +
+ +
+ Windows WSL2 host + + ```bash + export DISPLAY=$(grep nameserver /etc/resolv.conf | awk '{print $2}'):0 + export QT_X11_NO_MITSHM=1 + xhost +local:root + ``` +
+ +
+ macOS host + + Install [XQuartz](https://www.xquartz.org/) and enable **"Allow connections from network clients"** in XQuartz โ†’ Settings โ†’ Security. Log out and back in (or restart XQuartz) for the setting to take effect. + + ```bash + xhost + # allow connections (run on macOS host, not inside the container) + ``` + + Inside the devcontainer terminal, override the display variable: + ```bash + export DISPLAY=host.docker.internal:0 # Docker's built-in DNS to reach host XQuartz + ``` + + **Note:** The `postStartCommand` (`xhost +local:vscode || true`) runs inside the container and only works on Linux hosts with a shared X11 socket. On macOS, you must run `xhost` on the host side as shown above. +
+ +
+ NVIDIA GPU passthrough (Linux only) + + If you have an NVIDIA GPU and the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) installed, you can enable GPU passthrough by adding `"--gpus=all"` to `runArgs` in `.devcontainer/devcontainer.json`: + + ```json + "runArgs": [ + "--gpus=all", + ... + ] + ``` + + This flag is not included by default because it prevents the container from starting on macOS and Windows, where the NVIDIA Container Toolkit is not available. +
+ +4. You can now control the Turtlesim robot following the [Rosbridge setup](install/rosbridge.md) and [Connect to your robot](install/connect.md) guides. +5. Initialize pre-commit hooks (optional but recommended): + ```bash + pre-commit install + pre-commit run --all-files + ``` +6. Check Python code formatting with `ruff` + ```bash + ruff check . + ruff format --check . + ``` +
+ SSH Agent Setup for Git (click to expand) + + This should be run on the host side prior to building the devcontainer. + ```bash + # Start the SSH agent + eval "$(ssh-agent -s)" + + # List keys currently loaded + ssh-add -l + ``` + + If it says โ€œThe agent has no identitiesโ€, you must load your key, for example: + ```bash + ssh-add ~/.ssh/id_ed25519 + ssh-add -l # confirm fingerprint shows up + ``` +
+ + **Note:** This setup has been tested and verified on Ubuntu and macOS (Apple Silicon). + + +## License + +This project is licensed under the **Apache License 2.0**. By contributing to this project, you agree that your contributions will be licensed under the same license. + +**Key points for contributors:** +- Your contributions will be licensed under Apache 2.0 +- You retain copyright to your contributions +- You grant the project a perpetual, worldwide, non-exclusive license to use your contributions +- No additional legal agreements required for standard contributions + +For the full license text, see [LICENSE](../LICENSE) in the project root. diff --git a/docs/governance.md b/docs/governance.md new file mode 100644 index 0000000..af7dfa4 --- /dev/null +++ b/docs/governance.md @@ -0,0 +1,16 @@ +# Governance + +1. **Neutral Standards** + The technology specifications of ROS-MCP-Server will be openly discussed and decided through a Technical Steering Committee (TSC) that includes representatives from participating industrial and research partners. + +2. **Open Development** + All core code, demos, and tutorials will be developed in public repositories under a permissive open-source license (Apache 2.0). Discussions and proposals will be tracked transparently. + +3. **Fair Contribution Model** + Contributions will be accepted based on technical merit through the standard GitHub pull-request process, reviewed by maintainers. + +4. **Safety & Responsibility** + Given the power of AI-robotics, we commit to developing safety guidelines and best practices, reviewed by both technical and ethics advisors. + +5. **Commercial vs. Community Boundary** + ROS-MCP-Server core will always remain free and open. Contoro may offer commercial services (enterprise feature support, certified distributions, integrations, and others) on top, but the open standard will not be locked behind proprietary barriers. diff --git a/docs/images/MCP Demos Slide - 7to12s.gif b/docs/images/MCP Demos Slide - 7to12s.gif new file mode 100644 index 0000000..8a2105a Binary files /dev/null and b/docs/images/MCP Demos Slide - 7to12s.gif differ diff --git a/docs/images/MCP_topology.png b/docs/images/MCP_topology.png new file mode 100644 index 0000000..e2f7f4d Binary files /dev/null and b/docs/images/MCP_topology.png differ diff --git a/docs/images/ROS MCP Gripper vacuum test.jpg b/docs/images/ROS MCP Gripper vacuum test.jpg new file mode 100644 index 0000000..3b6eef3 Binary files /dev/null and b/docs/images/ROS MCP Gripper vacuum test.jpg differ diff --git a/docs/images/Wilson thumbnail.jpg b/docs/images/Wilson thumbnail.jpg new file mode 100644 index 0000000..25a4cdc Binary files /dev/null and b/docs/images/Wilson thumbnail.jpg differ diff --git a/docs/images/framework.png b/docs/images/framework.png new file mode 100644 index 0000000..bbf933c Binary files /dev/null and b/docs/images/framework.png differ diff --git a/docs/install/clients/chatgpt.md b/docs/install/clients/chatgpt.md new file mode 100644 index 0000000..0b178c5 --- /dev/null +++ b/docs/install/clients/chatgpt.md @@ -0,0 +1,86 @@ +# Step 1: ChatGPT Setup + +[ChatGPT Desktop](https://chatgpt.com/download) is OpenAI's desktop application. It supports MCP servers through its Connectors settings. Download it from [chatgpt.com/download](https://chatgpt.com/download) or the Microsoft Store. + +> **Recommended alternative:** ChatGPT's MCP setup is more complex than other clients because it requires an ngrok tunnel. For a simpler experience, consider using [Codex CLI](codex-cli.md) or [Claude Code](claude-code.md) instead. + +> **Note:** ChatGPT Desktop requires a public HTTPS URL to connect to MCP servers, so you'll need to run the MCP server in HTTP mode with an [ngrok](https://ngrok.com) tunnel. See the [HTTP transport](../http-transport.md) guide for details on the transport layer. + +## 1. Install uv + +[uv](https://docs.astral.sh/uv/) is needed to run the MCP server via `uvx`. + +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +``` + +See the [uv installation docs](https://docs.astral.sh/uv/getting-started/installation/) for other platforms or troubleshooting. + +## 2. Install and Configure ngrok + +[ngrok](https://ngrok.com) creates a public HTTPS tunnel to your local MCP server. + +1. Install ngrok from [ngrok.com/download](https://ngrok.com/download) +2. Create an account and add your authtoken: + ```bash + ngrok config add-authtoken + ``` +3. Get a free static domain from the [ngrok dashboard](https://dashboard.ngrok.com/domains) (e.g., `abc123-xyz789.ngrok-free.app`) + +## 3. Start the MCP Server and Tunnel + +Start the MCP server in HTTP mode: +```bash +uvx ros-mcp --transport streamable-http --host 127.0.0.1 --port 9000 +``` + +In a separate terminal, start the ngrok tunnel: +```bash +ngrok http --url=.ngrok-free.app 9000 +``` + +Verify the tunnel is working: +```bash +curl https://.ngrok-free.app/mcp +``` + +## 4. Configure ChatGPT + +1. Open ChatGPT Desktop +2. Go to **Settings** (bottom left) > **Connectors** +3. Create a new connector: + - **Name:** ROS-MCP Server + - **MCP Server URL:** `https://.ngrok-free.app/mcp` + - **Authentication:** No authentication + - Check **I trust this application** +4. In a new chat, click **+** > **Developer Mode** > **Add sources** > Activate **ROS-MCP Server** + +## 5. Verify the Setup + +Ask ChatGPT: + +``` +Connect to the robot on localhost using the ros-mcp server +``` + +The MCP server will attempt to reach a robot on the same machine. It should report that the IP is reachable but the rosbridge port is closed โ€” this confirms the MCP server is set up correctly. + +> **Tip:** If your AI assistant can't find the ros-mcp server, make sure both the MCP server and ngrok tunnel are still running, then restart ChatGPT Desktop. + +To complete the connection, [set up rosbridge](../rosbridge.md) on your robot. + +## Next Step + +Set up rosbridge on the machine where ROS is running: [Step 2: Rosbridge Setup](../rosbridge.md) + +--- + +### Advanced + +- Alternate installation methods: + - [Install via pip](../pip.md) โ€” traditional `pip install` or install from source with pip + - [Install from source](../from-source.md) โ€” for developers who need to modify the server code + +--- + +[Back to Installation Guide](../installation.md) | [Troubleshooting](../troubleshooting.md) diff --git a/docs/install/clients/claude-code.md b/docs/install/clients/claude-code.md new file mode 100644 index 0000000..decf99d --- /dev/null +++ b/docs/install/clients/claude-code.md @@ -0,0 +1,66 @@ +# Step 1: Claude Code Setup + +[Claude Code](https://docs.anthropic.com/en/docs/claude-code/overview) is Anthropic's CLI tool for working with Claude. It supports MCP servers natively. If you don't have it yet, see the [installation instructions](https://docs.anthropic.com/en/docs/claude-code/overview#getting-started). + +## 1. Install uv + +[uv](https://docs.astral.sh/uv/) is needed to run the MCP server via `uvx`. + +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +``` + +See the [uv installation docs](https://docs.astral.sh/uv/getting-started/installation/) for other platforms or troubleshooting. + +## 2. Add the MCP Server + +Add the ROS-MCP server to Claude Code: + +```bash +claude mcp add ros-mcp -- uvx ros-mcp --transport=stdio +``` + +This registers the server so that Claude Code will launch it automatically when needed. + +## 3. Verify the Setup + +Verify the server was added: + +```bash +claude mcp list +``` + +You should see `ros-mcp` in the output. + +Now start Claude Code and ask it to connect to a robot: + +```bash +claude +``` + +``` +Connect to the robot on localhost using the ros-mcp server +``` + +The MCP server will attempt to reach a robot on the same machine. It should report that the IP is reachable but the rosbridge port is closed โ€” this confirms the MCP server is set up correctly. + +> **Tip:** If your AI assistant can't find the ros-mcp server, exit and restart Claude Code so it picks up the new configuration. + +To complete the connection, [set up rosbridge](../rosbridge.md) on your robot. + +## Next Step + +Set up rosbridge on the machine where ROS is running: [Step 2: Rosbridge Setup](../rosbridge.md) + +--- + +### Advanced + +- [HTTP transport](../http-transport.md) โ€” run the MCP server as a standalone HTTP service using the MCP http transport instead of default stdio transport. +- Alternate installation methods: + - [Install via pip](../pip.md) โ€” traditional `pip install` or install from source with pip + - [Install from source](../from-source.md) โ€” for developers who need to modify the server code + +--- + +[Back to Installation Guide](../installation.md) | [Troubleshooting](../troubleshooting.md) diff --git a/docs/install/clients/claude-desktop.md b/docs/install/clients/claude-desktop.md new file mode 100644 index 0000000..c0984bf --- /dev/null +++ b/docs/install/clients/claude-desktop.md @@ -0,0 +1,115 @@ +# Step 1: Claude Desktop Setup + +[Claude Desktop](https://claude.ai/download) is Anthropic's desktop application for Claude. It supports MCP servers through a JSON configuration file. + +- **Linux**: Install via the community-supported [claude-desktop-debian](https://github.com/aaddrick/claude-desktop-debian) +- **macOS / Windows**: Download from [claude.ai/download](https://claude.ai/download) + +## 1. Install uv + +[uv](https://docs.astral.sh/uv/) is needed to run the MCP server via `uvx`. + +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +``` + +See the [uv installation docs](https://docs.astral.sh/uv/getting-started/installation/) for other platforms or troubleshooting. + +## 2. Add the MCP Server + +> โš ๏ธ **Important**: Quit Claude Desktop **completely** before editing `claude_desktop_config.json`. If the app is running in the background when you save the file, your changes can be silently overwritten the moment you enter a chat. Terminate the process first: +> +> - **Linux:** `pkill -f claude-desktop` +> - **macOS:** Right-click the dock icon โ†’ Quit, or `pkill -f -i claude` in Terminal +> - **Windows:** End the `Claude` task in Task Manager (Ctrl+Shift+Esc โ†’ find *Claude* โ†’ End task) + +Locate and edit the `claude_desktop_config.json` file (create it if it doesn't exist): + +| OS | Config file path | +|----|-----------------| +| Linux | `~/.config/Claude/claude_desktop_config.json` | +| macOS | `~/Library/Application Support/Claude/claude_desktop_config.json` | +| Windows | `%APPDATA%\Claude\claude_desktop_config.json` | + +Add the following to the file: + +**Linux:** +```json +{ + "mcpServers": { + "ros-mcp-server": { + "command": "bash", + "args": ["-lc", "uvx ros-mcp --transport=stdio"] + } + } +} +``` + +**macOS:** +```json +{ + "mcpServers": { + "ros-mcp-server": { + "command": "zsh", + "args": ["-lc", "uvx ros-mcp --transport=stdio"] + } + } +} +``` + +> **macOS: "Could not attach to MCP server"?** `zsh -lc` sources `~/.zprofile` but not `~/.zshrc`, where the `uv` installer puts `~/.local/bin` on your `PATH` โ€” so the subprocess can't find `uvx`. Fix it by adding `~/.local/bin` to `~/.zprofile`, or by using the absolute path to `uvx`. See [Troubleshooting](../troubleshooting.md#macos-could-not-attach-to-mcp-server--uvx-not-found). + +**Windows (WSL):** +```json +{ + "mcpServers": { + "ros-mcp-server": { + "command": "wsl", + "args": [ + "-d", "Ubuntu-22.04", + "bash", "-lc", + "uvx ros-mcp --transport=stdio" + ] + } + } +} +``` + +> **WSL users**: Replace `"Ubuntu-22.04"` with your actual WSL distribution name. Check with `wsl --list --verbose`. + +## 3. Verify the Setup + +1. Completely restart Claude Desktop โ€” the MCP server list is cached on startup. If it doesn't appear, fully terminate the app and relaunch: + - **Linux:** `pkill -f claude-desktop && claude-desktop` + - **macOS:** Right-click the dock icon > Quit, or use Activity Monitor to force quit + - **Windows:** End the task in Task Manager, then relaunch +2. Check that **ros-mcp-server** appears in your list of tools. + +> **Tip:** If you're having trouble with the config file, ask Claude to help set it up. Tell it that the command to run the MCP server is `uvx ros-mcp` and it can generate the correct configuration for your OS. + +Once the server appears, try asking Claude: + +``` +Connect to the robot on localhost using the ros-mcp server +``` + +The MCP server will attempt to reach a robot on the same machine. It should report that the IP is reachable but the rosbridge port is closed โ€” this confirms the MCP server is set up correctly. + +To complete the connection, [set up rosbridge](../rosbridge.md) on your robot. + +## Next Step + +Set up rosbridge on the machine where ROS is running: [Step 2: Rosbridge Setup](../rosbridge.md) + +--- + +### Advanced + +- [HTTP transport](../http-transport.md) โ€” run the MCP server as a standalone HTTP service instead of stdio +- Alternate installation methods: + - [Install via pip](../pip.md) โ€” traditional `pip install` or install from source with pip + - [Install from source](../from-source.md) โ€” for developers who need to modify the server code + +--- + +[Back to Installation Guide](../installation.md) | [Troubleshooting](../troubleshooting.md) diff --git a/docs/install/clients/codex-cli.md b/docs/install/clients/codex-cli.md new file mode 100644 index 0000000..f6403ff --- /dev/null +++ b/docs/install/clients/codex-cli.md @@ -0,0 +1,65 @@ +# Step 1: Codex CLI Setup + +[Codex CLI](https://github.com/openai/codex) is OpenAI's CLI agent for software development. It supports MCP servers natively. If you don't have it yet, see the [installation instructions](https://github.com/openai/codex#getting-started). + +## 1. Install uv + +[uv](https://docs.astral.sh/uv/) is needed to run the MCP server via `uvx`. + +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +``` + +See the [uv installation docs](https://docs.astral.sh/uv/getting-started/installation/) for other platforms or troubleshooting. + +## 2. Add the MCP Server + +Edit your Codex CLI configuration file at `~/.codex/config.json` and add the MCP server: + +```json +{ + "mcpServers": { + "ros-mcp-server": { + "command": "uvx", + "args": ["ros-mcp", "--transport=stdio"] + } + } +} +``` + +> **Tip:** If you're having trouble with the config file, ask Codex to help set it up. Tell it that the command to run the MCP server is `uvx ros-mcp` and it can generate the correct configuration. + +## 3. Verify the Setup + +Start Codex CLI and ask it to connect to a robot: + +```bash +codex +``` + +``` +Connect to the robot on localhost using the ros-mcp server +``` + +The MCP server will attempt to reach a robot on the same machine. It should report that the IP is reachable but the rosbridge port is closed โ€” this confirms the MCP server is set up correctly. + +> **Tip:** If your AI assistant can't find the ros-mcp server, exit and restart Codex CLI so it picks up the new configuration. + +To complete the connection, [set up rosbridge](../rosbridge.md) on your robot. + +## Next Step + +Set up rosbridge on the machine where ROS is running: [Step 2: Rosbridge Setup](../rosbridge.md) + +--- + +### Advanced + +- [HTTP transport](../http-transport.md) โ€” run the MCP server as a standalone HTTP service instead of stdio +- Alternate installation methods: + - [Install via pip](../pip.md) โ€” traditional `pip install` or install from source with pip + - [Install from source](../from-source.md) โ€” for developers who need to modify the server code + +--- + +[Back to Installation Guide](../installation.md) | [Troubleshooting](../troubleshooting.md) diff --git a/docs/install/clients/cursor.md b/docs/install/clients/cursor.md new file mode 100644 index 0000000..b565bf0 --- /dev/null +++ b/docs/install/clients/cursor.md @@ -0,0 +1,80 @@ +# Step 1: Cursor Setup + +[Cursor](https://cursor.com) is an AI-powered IDE. It supports MCP servers through its settings. If you don't have it yet, download it from [cursor.com/downloads](https://cursor.com/downloads). + +## 1. Install uv + +[uv](https://docs.astral.sh/uv/) is needed to run the MCP server via `uvx`. + +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +``` + +See the [uv installation docs](https://docs.astral.sh/uv/getting-started/installation/) for other platforms or troubleshooting. + +## 2. Add the MCP Server + +In Cursor, go to **Settings > MCP > New MCP Server** and edit the `mcp.json` file: + +**Linux / macOS:** +```json +{ + "mcpServers": { + "ros-mcp-server": { + "command": "uvx", + "args": ["ros-mcp", "--transport=stdio"] + } + } +} +``` + +**Windows (WSL):** +```json +{ + "mcpServers": { + "ros-mcp-server": { + "command": "wsl", + "args": [ + "-d", "Ubuntu", + "/home//.local/bin/uvx", + "ros-mcp", "--transport=stdio" + ] + } + } +} +``` + +> **WSL users**: Replace `` with your WSL username and `"Ubuntu"` with your distribution name. Verify the path to uvx with `wsl -- which uvx`. + +> **Tip:** If you're having trouble with the config file, ask Cursor to help set it up. Tell it that the command to run the MCP server is `uvx ros-mcp` and it can generate the correct configuration. + +## 3. Verify the Setup + +Open a new chat in Cursor and ask: + +``` +Connect to the robot on localhost using the ros-mcp server +``` + +The MCP server will attempt to reach a robot on the same machine. It should report that the IP is reachable but the rosbridge port is closed โ€” this confirms the MCP server is set up correctly. + +> **Tip:** If your AI assistant can't find the ros-mcp server, restart Cursor so it picks up the new configuration. + +To complete the connection, [set up rosbridge](../rosbridge.md) on your robot. + +## Next Step + +Set up rosbridge on the machine where ROS is running: [Step 2: Rosbridge Setup](../rosbridge.md) + +--- + +### Advanced + +- [HTTP transport](../http-transport.md) โ€” run the MCP server as a standalone HTTP service instead of stdio +- Alternate installation methods: + - [Install via pip](../pip.md) โ€” traditional `pip install` or install from source with pip + - [Install from source](../from-source.md) โ€” for developers who need to modify the server code + +--- + +[Back to Installation Guide](../installation.md) | [Troubleshooting](../troubleshooting.md) diff --git a/docs/install/clients/custom-client.md b/docs/install/clients/custom-client.md new file mode 100644 index 0000000..9e71519 --- /dev/null +++ b/docs/install/clients/custom-client.md @@ -0,0 +1,51 @@ +# Step 1: Custom / Programmatic Client + +You can use the ROS-MCP server directly in your Python code using the [MCP SDK](https://github.com/modelcontextprotocol/python-sdk). + +## Example + +```python +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client + +async def main(): + server_params = StdioServerParameters( + command="uvx", + args=["ros-mcp", "--transport=stdio"] + ) + + async with stdio_client(server_params) as (read, write): + async with ClientSession(read, write) as session: + # Initialize the session + await session.initialize() + + # List available tools + tools = await session.list_tools() + print(tools) + + # Call a tool + result = await session.call_tool("get_topics", {}) + print(result) +``` + +## Prerequisites + +- **uv** installed โ€” see [uv installation docs](https://docs.astral.sh/uv/getting-started/installation/) +- **MCP Python SDK** โ€” `pip install mcp` + +## Next Step + +Set up rosbridge on the machine where ROS is running: [Step 2: Rosbridge Setup](../rosbridge.md) + +--- + +### Advanced + +- [HTTP transport](../http-transport.md) โ€” run the MCP server as a standalone HTTP service instead of stdio +- Alternate installation methods: + - [Install via pip](../pip.md) โ€” traditional `pip install` or install from source with pip + - [Install from source](../from-source.md) โ€” for developers who need to modify the server code + +--- + +[Back to Installation Guide](../installation.md) | [Troubleshooting](../troubleshooting.md) diff --git a/docs/install/clients/gemini-cli.md b/docs/install/clients/gemini-cli.md new file mode 100644 index 0000000..086e663 --- /dev/null +++ b/docs/install/clients/gemini-cli.md @@ -0,0 +1,65 @@ +# Step 1: Gemini CLI Setup + +[Gemini CLI](https://github.com/google-gemini/gemini-cli) is Google's CLI for Gemini. It supports MCP servers through a JSON settings file. If you don't have it yet, see the [installation instructions](https://github.com/google-gemini/gemini-cli#installation). + +## 1. Install uv + +[uv](https://docs.astral.sh/uv/) is needed to run the MCP server via `uvx`. + +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +``` + +See the [uv installation docs](https://docs.astral.sh/uv/getting-started/installation/) for other platforms or troubleshooting. + +## 2. Add the MCP Server + +Edit your Gemini CLI settings file at `~/.gemini/settings.json` and add the MCP server: + +```json +{ + "mcpServers": { + "ros-mcp-server": { + "command": "uvx", + "args": ["ros-mcp", "--transport=stdio"] + } + } +} +``` + +> **Tip:** If you're having trouble with the config file, ask Gemini to help set it up. Tell it that the command to run the MCP server is `uvx ros-mcp` and it can generate the correct configuration. + +## 3. Verify the Setup + +Start Gemini CLI and ask it to connect to a robot: + +```bash +gemini +``` + +``` +Connect to the robot on localhost using the ros-mcp server +``` + +The MCP server will attempt to reach a robot on the same machine. It should report that the IP is reachable but the rosbridge port is closed โ€” this confirms the MCP server is set up correctly. + +> **Tip:** If your AI assistant can't find the ros-mcp server, exit and restart Gemini CLI so it picks up the new configuration. + +To complete the connection, [set up rosbridge](../rosbridge.md) on your robot. + +## Next Step + +Set up rosbridge on the machine where ROS is running: [Step 2: Rosbridge Setup](../rosbridge.md) + +--- + +### Advanced + +- [HTTP transport](../http-transport.md) โ€” run the MCP server as a standalone HTTP service instead of stdio +- Alternate installation methods: + - [Install via pip](../pip.md) โ€” traditional `pip install` or install from source with pip + - [Install from source](../from-source.md) โ€” for developers who need to modify the server code + +--- + +[Back to Installation Guide](../installation.md) | [Troubleshooting](../troubleshooting.md) diff --git a/docs/install/clients/robot-mcp-client.md b/docs/install/clients/robot-mcp-client.md new file mode 100644 index 0000000..171d413 --- /dev/null +++ b/docs/install/clients/robot-mcp-client.md @@ -0,0 +1,26 @@ +# Step 1: Robot MCP Client Setup + +[Robot MCP Client](https://github.com/robotmcp/robot-mcp-client) is a lightweight terminal-based client for interacting with the MCP server without desktop LLM applications. + +It supports the following LLMs, running natively in the terminal: + +- **Anthropic:** Claude Sonnet 4.5 +- **OpenAI:** GPT-4.1 +- **Google Gemini:** Gemini 2.5 Flash Lite +- **Groq (open-source models):** + - Llama 4 Scout 17B + - Llama 3.1 8B Instant + - Llama 3.3 70B Versatile + - OpenAI GPT-OSS 120B + +## Setup + +See the [Robot MCP Client repository](https://github.com/robotmcp/robot-mcp-client) for installation and usage instructions. + +## Next Step + +Set up rosbridge on the machine where ROS is running: [Step 2: Rosbridge Setup](../rosbridge.md) + +--- + +[Back to Installation Guide](../installation.md) | [Troubleshooting](../troubleshooting.md) diff --git a/docs/install/connect.md b/docs/install/connect.md new file mode 100644 index 0000000..80057cb --- /dev/null +++ b/docs/install/connect.md @@ -0,0 +1,74 @@ +# Step 3: Connect to Your Robot + +Now that your AI client has the MCP server configured and rosbridge is running on the robot, you're ready to connect. + +## 1. Connect + +Open your AI client and tell it to connect to the robot: + +``` +Connect to the robot at +``` + +Replace `` with your robot's IP address on the local network (e.g., `192.168.1.42`). If the MCP server and ROS are on the same machine, use `localhost`. + +The MCP server will report that the IP is reachable and the rosbridge port is open โ€” this means you're connected. + +> Make sure the rosbridge port (default 9090) is not blocked by a firewall on the robot's machine. + +## 2. Explore + +Once connected, try asking your AI client to explore the ROS system: + +``` +What topics and services are available on the robot? +``` + +``` +What nodes are currently running? +``` + +The MCP server will query rosbridge and return the results from the robot's ROS environment. + +## 3. Try It Out + +You can interact with the robot using natural language: + +``` +Make the robot move forward +``` + +``` +Subscribe to the /odom topic and show me the latest message +``` + +If you don't have a physical robot, turtlesim is the standard "hello world" for ROS and is a great option to explore and experiment. Launch it using: + +**ROS 2:** +```bash +ros2 run turtlesim turtlesim_node +``` + +**ROS 1:** +```bash +rosrun turtlesim turtlesim_node +``` + +For a full walkthrough, see the [Turtlesim Tutorial](../../examples/1_turtlesim/README.md). + +## More Examples + +This repo includes several examples to try with different robots and setups: + +- [Turtlesim](../../examples/1_turtlesim/README.md) โ€” the "hello world" of ROS +- [Turtlesim with Docker](../../examples/5_docker_turtlesim/README.md) โ€” no ROS install required +- [LIMO Mobile Robot](../../examples/3_limo_mobile_robot/real_robot/README.md) +- [Unitree Go2](../../examples/4_unitree_go2/real_robot/README.md) +- [TurtleBot3](../../examples/9_turtlebot3/README.md) +- [Image Topics](../../examples/8_images/README.md) + +For more advanced demos with simulated robots in Gazebo, see the [ROS-MCP Demos repository](https://github.com/robotmcp/demos-ros-mcp-server) which includes a warehouse TugBot, Unitree Go2 quadruped, and drone control with PX4. + +--- + +[Back to Installation Guide](installation.md) | [Troubleshooting](troubleshooting.md) diff --git a/docs/install/from-source.md b/docs/install/from-source.md new file mode 100644 index 0000000..b332dfd --- /dev/null +++ b/docs/install/from-source.md @@ -0,0 +1,72 @@ +# Installation from Source + +This guide is for developers who need to modify the ROS-MCP server source code. For most users, we recommend the standard [installation via uvx](clients/claude-code.md). + +## 1. Clone the Repository + +```bash +git clone https://github.com/robotmcp/ros-mcp-server.git +``` + +> **WSL Users**: Clone in your WSL home directory (e.g., `/home/username/`), not the Windows filesystem mount (e.g., `/mnt/c/Users/username/`). The native Linux filesystem provides better performance and avoids permission issues. + +## 2. Install uv + +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +``` + +See the [uv installation docs](https://docs.astral.sh/uv/getting-started/installation/) for other platforms or troubleshooting. + +## 3. Install Dependencies + +```bash +cd ros-mcp-server +uv sync +``` + +## 4. Test the Server + +```bash +uv run server.py --help +``` + +If this prints the help output, the server is installed correctly and ready to run. + +## 5. Configure Your AI Client + +When running from source, use `uv run server.py` with the path to the cloned repository instead of `uvx ros-mcp`. + +The recommended approach is to use [HTTP transport](http-transport.md), which runs the server as a standalone process: + +```bash +uv run server.py --transport streamable-http --host 127.0.0.1 --port 9000 +``` + +Then point your AI client to `http://127.0.0.1:9000/mcp`. See the [HTTP transport](http-transport.md) page for client configuration details. + +Alternatively, you can configure your client to launch the server directly via stdio. For example, with Claude Code: + +```bash +claude mcp add ros-mcp -- uv --directory //ros-mcp-server run server.py --transport=stdio +``` + +For clients that use a JSON config file: +```json +{ + "command": "uv", + "args": [ + "--directory", "//ros-mcp-server", + "run", "server.py", "--transport=stdio" + ] +} +``` + +## Next Steps + +- [Set up your AI client](installation.md#step-1-set-up-your-ai-client) +- [Set up rosbridge on the robot](rosbridge.md) + +--- + +[Back to Installation Guide](installation.md) | [Troubleshooting](troubleshooting.md) diff --git a/docs/install/http-transport.md b/docs/install/http-transport.md new file mode 100644 index 0000000..cbbf780 --- /dev/null +++ b/docs/install/http-transport.md @@ -0,0 +1,63 @@ +# HTTP Transport + +By default, AI clients launch the MCP server automatically using the **stdio** transport. This is the simplest setup and works for most users. + +As an alternative, you can run the MCP server as a standalone HTTP service using the **streamable-http** transport. This is useful when the MCP server needs to be accessed by multiple clients, run on a different machine from the AI client (for example run on the robot), or when [installing the MCP server from source](from-source.md). + +## When to Use HTTP Transport + +| | STDIO (Default) | HTTP/Streamable-HTTP | +|---|---|---| +| **Best for** | Local development, single-user setups | Remote access, multiple clients, production deployments | +| **Pros** | Simple setup, no network configuration needed | Network accessible, multiple clients can connect | +| **Cons** | MCP server and AI client must be on the same machine | Requires network configuration, server must be started manually | +| **Use case** | Running MCP server directly with your AI client | Remote robots, team environments, web-based clients, development | + +## Start the MCP Server in HTTP Mode + +```bash +uvx ros-mcp --transport streamable-http --host 127.0.0.1 --port 9000 +``` + +The server will start listening at `http://127.0.0.1:9000/mcp`. + +To make it accessible from other machines on the network, use `--host 0.0.0.0`. + +## Configure Your AI Client + +Point your AI client to the MCP server's HTTP endpoint. The configuration varies by client, but the URL is the same: + +``` +http://:9000/mcp +``` + +Example JSON configuration (used by Claude Desktop, Cursor, and others): + +```json +{ + "mcpServers": { + "ros-mcp-server-http": { + "name": "ROS-MCP Server (http)", + "transport": "http", + "url": "http://127.0.0.1:9000/mcp" + } + } +} +``` + +## Environment Variables (Legacy) + +The server can also be configured using environment variables instead of command-line arguments: + +```bash +export MCP_TRANSPORT=streamable-http +export MCP_HOST=127.0.0.1 +export MCP_PORT=9000 +uvx ros-mcp +``` + +Command-line arguments take precedence over environment variables. + +--- + +For the default stdio setup, see the [client setup guides](installation.md#step-1-set-up-your-ai-client). diff --git a/docs/install/installation.md b/docs/install/installation.md new file mode 100644 index 0000000..e4fd169 --- /dev/null +++ b/docs/install/installation.md @@ -0,0 +1,73 @@ +# Installation Guide + +The ROS-MCP server lets any [MCP-compatible](https://modelcontextprotocol.io/) AI assistant control a robot running ROS โ€” even from a different machine on the network. + +Setup spans two machines on the **same local network** (or one machine if your AI client runs alongside ROS on the same hardware). A VPN is a great option for connecting over the internet. + +| Machine | What to install | Prerequisites | Purpose | +|---------|----------------|---------------|---------| +| **Your machine** (laptop/desktop) | An AI client + the ROS-MCP server | An account with an AI provider (e.g., Claude, Codex, Gemini) | Runs the language model and the MCP server | +| **The robot's machine** | rosbridge | ROS installed | Bridges ROS over WebSocket for the MCP server to connect to | + +Follow the three steps below to get up and running. Each step includes quick inline commands and a link to a more detailed guide. + +--- + +## Step 1: Set Up Your AI Client + + +Quick setup with Claude Code: + +```bash +# On your machine: +# 1.1. Install uv (Python package runner) +curl -LsSf https://astral.sh/uv/install.sh | sh + +# 1.2. Add the MCP server to Claude Code +claude mcp add ros-mcp -- uvx ros-mcp --transport=stdio +``` + +For detailed instructions or to set up a different AI client, follow the guide for your client below. + + +| Client | Description | Guide | +|--------|-------------|-------| +| **Claude Code** (Recommended) | Anthropic's CLI for Claude | [Setup guide](clients/claude-code.md) | +| Codex CLI | OpenAI's CLI agent | [Setup guide](clients/codex-cli.md) | +| Gemini CLI | Google's CLI for Gemini | [Setup guide](clients/gemini-cli.md) | +| Claude Desktop | Anthropic's desktop app | [Setup guide](clients/claude-desktop.md) | +| ChatGPT | OpenAI's desktop app | [Setup guide](clients/chatgpt.md) | +| Cursor | AI-powered IDE | [Setup guide](clients/cursor.md) | +| Robot MCP Client | Lightweight terminal client | [Setup guide](clients/robot-mcp-client.md) | +| Custom / Programmatic | Python MCP SDK | [Setup guide](clients/custom-client.md) | + +## Step 2: Set Up Rosbridge on the Robot + +Install and launch rosbridge on the machine where ROS is running. See the [Step 2: Rosbridge setup guide](rosbridge.md) for detailed instructions. Quick setup below: + +```bash +# On the robot: +# 2.1. Install Rosbridge +sudo apt update +sudo apt install ros--rosbridge-server +``` +```bash +# 2.2. Launch Rosbridge +source //install/setup.bash +ros2 launch rosbridge_server rosbridge_websocket_launch.xml +``` + +## Step 3: Connect to Your Robot + +See the [Step 3: Connect and explore](connect.md) guide for connecting to your robot and sample commands. For a quick start, launch your AI assistant and type: +``` +Connect to the robot on and tell me what topics and services you see. +``` + +--- + +## Additional Resources + +- [Troubleshooting](troubleshooting.md) โ€” common issues and debug commands +- [Examples](../../examples/) โ€” tutorials for turtlesim, Unitree Go2, LIMO, TurtleBot3, and more +- [ROS-MCP Demos](https://github.com/robotmcp/demos-ros-mcp-server) โ€” advanced demos with simulated robots in Gazebo diff --git a/docs/install/pip.md b/docs/install/pip.md new file mode 100644 index 0000000..8106ee9 --- /dev/null +++ b/docs/install/pip.md @@ -0,0 +1,51 @@ +# Install via pip + +For most users, we recommend installing via [uvx](clients/claude-code.md) instead. This page covers alternative pip-based installation methods. + +## pip install + +```bash +pip install ros-mcp +``` + +> **Requirements:** pip 23.0+ and Python 3.10+. Check your versions with `pip --version` and `python3 --version`. Upgrade pip if needed: +> ```bash +> python3 -m pip install --upgrade pip +> ``` + +## pip install from source + +```bash +git clone https://github.com/robotmcp/ros-mcp-server.git +cd ros-mcp-server +pip install . +``` + +> **Requirements:** pip 23.0+ and Python 3.10+. + +## Configuring Your AI Client + +When using pip install, the `ros-mcp` command is installed directly into your environment. Use `ros-mcp` instead of `uvx ros-mcp` when configuring your AI client. + +For example, with Claude Code: +```bash +claude mcp add ros-mcp -- ros-mcp --transport=stdio +``` + +For clients that use a JSON config file: +```json +{ + "command": "ros-mcp", + "args": ["--transport=stdio"] +} +``` + +## Next Steps + +- [Set up your AI client](installation.md#step-1-set-up-your-ai-client) +- [Set up rosbridge on the robot](rosbridge.md) +- [Troubleshooting](troubleshooting.md) + +--- + +[Back to Installation Guide](installation.md) | [Troubleshooting](troubleshooting.md) diff --git a/docs/install/rosbridge.md b/docs/install/rosbridge.md new file mode 100644 index 0000000..d839a5a --- /dev/null +++ b/docs/install/rosbridge.md @@ -0,0 +1,94 @@ +# Step 2: Setting Up Rosbridge + +Rosbridge runs on the **robot's machine** (wherever ROS is running). It provides a WebSocket interface that the MCP server on your machine connects to over the network. + +> **Prerequisite:** ROS must already be installed on the robot's machine. If you don't have ROS installed and want to try things out quickly, see the [Turtlesim Docker example](../../examples/5_docker_turtlesim/README.md) which runs ROS and rosbridge in a container. + +> **Important โ€” rosbridge *and* rosapi are both required.** ros-mcp-server needs `rosbridge_server` (the WebSocket interface) **and** `rosapi` (the introspection services behind `get_nodes`, `get_topics`, `get_services`, `detect_ros_version`, parameter tools, etc.). The **launch file** used below starts both automatically. Do **not** start rosbridge with `ros2 run rosbridge_server ...` / `rosrun rosbridge_server ...` โ€” those bring up the WebSocket *without* `rosapi`, and every introspection tool will fail with "Service does not exist". The `ros--rosbridge-server` package pulls `rosapi` in as a dependency; if in doubt, install the full `ros--rosbridge-suite` metapackage. + +## Install rosbridge_server + +For ROS 2 Jazzy: +```bash +# Update package list +sudo apt update +# Install rosbridge for ROS2 Jazzy +sudo apt install ros-jazzy-rosbridge-server +``` + +For other ROS 2 distros: +```bash +# Install rosbridge for ROS2 Humble +sudo apt install ros-humble-rosbridge-server +``` +```bash +# Install for other ROS distros +sudo apt install ros--rosbridge-server +``` + +> Using ROS 1? See [ROS 1 instructions](#ros-1-end-of-life) at the bottom of this page. + +## Launch rosbridge +`source` your ROS workspace first to ensure that the rosbridge has access to your ROS system. + +```bash +ros2 launch rosbridge_server rosbridge_websocket_launch.xml +``` + +> Don't forget to `source` your ROS workspace before launching, especially if you're using custom messages or services. + +## Verify rosbridge is running + +From the robot's machine: +```bash +curl -I http://localhost:9090 +``` + +A successful response confirms rosbridge is listening on its default port (9090). + +Also confirm `rosapi` is running โ€” its services must be present for the MCP tools to work: +```bash +# ROS 2 +ros2 service list | grep rosapi + +# ROS 1 +rosservice list | grep rosapi +``` +If this prints nothing, you launched rosbridge without `rosapi` โ€” use the `ros2 launch` / `roslaunch` command above rather than `ros2 run` / `rosrun`. + +## Next Step + +Connect to your robot and test it out: [Step 3: Connect](connect.md) + +--- + +### Advanced + +
+ROS 1 (End of Life) + +#### Install rosbridge_server + +For ROS Noetic: +```bash +sudo apt install ros-noetic-rosbridge-server +``` + +For other ROS 1 distros: +```bash +sudo apt install ros--rosbridge-server +``` + +#### Launch rosbridge + +```bash +roslaunch rosbridge_server rosbridge_websocket.launch +``` + +> Don't forget to `source` your ROS workspace before launching, especially if you're using custom messages or services. + +
+ +--- + +[Back to Installation Guide](installation.md) | [Troubleshooting](troubleshooting.md) diff --git a/docs/install/troubleshooting.md b/docs/install/troubleshooting.md new file mode 100644 index 0000000..e0470b4 --- /dev/null +++ b/docs/install/troubleshooting.md @@ -0,0 +1,123 @@ +# Troubleshooting + +[Back to Installation Guide](installation.md) + +## MCP Server Not Appearing in Client + +**Symptoms:** The ros-mcp-server doesn't show up in your AI client's tool list. + +**Solutions:** +1. **Restart your client completely** โ€” some clients cache MCP server state on startup. +2. **Test the server manually** to check for install issues: + ```bash + uvx ros-mcp --help + ``` +3. **Check your client's logs** for error messages related to MCP server initialization. + +## macOS: "Could not attach to MCP server" / `uvx` Not Found + +**Symptoms:** On macOS, Claude Desktop reports "Could not attach to MCP server ros-mcp-server", even though `uvx ros-mcp --help` works fine in your terminal. + +**Cause:** The default macOS config launches the server with `zsh -lc`, which starts a **login, non-interactive** shell. That sources `~/.zprofile` but **not** `~/.zshrc`. The `uv` installer adds `~/.local/bin` to your `PATH` in `~/.zshrc` only, so the subprocess Claude Desktop spawns can't find `uvx`. + +Reproduce the clean-subprocess environment Claude Desktop uses: +```bash +env -i HOME="$HOME" USER="$USER" zsh -lc 'which uvx' +# "uvx not found" โ†’ you are hitting this issue +``` + +**Solutions** (pick one): + +1. **Make `~/.local/bin` available to login shells** โ€” add it to `~/.zprofile` (which `zsh -lc` *does* source): + ```bash + echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zprofile + ``` + Then fully restart Claude Desktop. + +2. **Use the absolute path to `uvx`** in `claude_desktop_config.json` instead of going through a shell. Find it with `which uvx` (usually `~/.local/bin/uvx`): + ```json + { + "mcpServers": { + "ros-mcp-server": { + "command": "/Users//.local/bin/uvx", + "args": ["ros-mcp", "--transport=stdio"] + } + } + } + ``` + +## Connection Refused / Cannot Reach Rosbridge + +**Symptoms:** "Connection refused", "No valid session ID provided", or timeout errors when trying to interact with ROS. + +**Solutions:** +1. **Verify rosbridge is running** on the robot's machine: + ```bash + # ROS 2 + ros2 topic list + + # ROS 1 + rostopic list + ``` +2. **Test rosbridge directly** from the robot's machine: + ```bash + curl -I http://localhost:9090 + ``` +3. **Check the IP address** โ€” if the robot is on a different machine, make sure you're using the correct IP and that both machines are on the same network. +4. **Check firewall rules** โ€” ensure port 9090 (rosbridge default) is open on the robot's machine. + +## WSL-Specific Issues + +**Symptoms:** Issues when running on Windows with WSL. + +**Solutions:** +1. **Use the correct WSL distribution name** in your MCP config (e.g., `"Ubuntu-22.04"` not `"Ubuntu"`). Check with: + ```bash + wsl --list --verbose + ``` +2. **Clone repos in the Linux filesystem** โ€” use `/home/username/`, not `/mnt/c/Users/username/`. The Windows filesystem mount has poor performance and can cause permission issues. +3. **Test the server in WSL directly:** + ```bash + uvx ros-mcp --help + ``` + +## HTTP Transport Issues + +**Symptoms:** HTTP transport not working or connection timeouts. + +**Solutions:** +1. **Check the server is running** โ€” HTTP transport requires starting the server manually: + ```bash + uvx ros-mcp --transport streamable-http --host 127.0.0.1 --port 9000 + ``` +2. **Verify port availability:** + ```bash + netstat -tulpn | grep :9000 + ``` +3. **Test the endpoint directly:** + ```bash + curl http://localhost:9000/mcp + ``` +4. **Check firewall rules** if accessing from another machine. + +## Debug Commands + +| What to check | Command | +|---------------|---------| +| ROS 2 topics | `ros2 topic list` | +| ROS 1 topics | `rostopic list` | +| Rosbridge reachable | `curl -I http://localhost:9090` | +| MCP server works | `uvx ros-mcp --help` | +| Running processes | `ps aux \| grep rosbridge` | +| WSL distributions | `wsl --list --verbose` | + +## Still Having Issues? + +1. **Check logs** โ€” look for error messages in your AI client and MCP server output. Running logs through an LLM can help with debugging. +2. **Test with turtlesim** โ€” verify basic functionality with the [Turtlesim Tutorial](../../examples/1_turtlesim/README.md). +3. **Open an issue** on the [GitHub repository](https://github.com/robotmcp/ros-mcp-server/issues) with: + - Your operating system + - ROS version + - AI client being used + - Error messages + - Steps to reproduce diff --git a/docs/launch_system.md b/docs/launch_system.md new file mode 100644 index 0000000..7535d63 --- /dev/null +++ b/docs/launch_system.md @@ -0,0 +1,142 @@ +# ROS2 Launch System for Robot Integration + +## Overview + +This guide provides template launch files for developers to integrate their robots with the ROS-MCP Server. These templates demonstrate how to combine your robot's existing launch files with rosbridge for MCP communication. + +## Template Launch Files + +### `ros_mcp_rosbridge.launch.py` - Basic Rosbridge Template +**Purpose**: Minimal rosbridge WebSocket server for robot integration +**Use Case**: Add MCP communication to any existing robot setup + +```bash +# Basic usage +ros2 launch ros_mcp_rosbridge.launch.py + +# Custom port +ros2 launch ros_mcp_rosbridge.launch.py port:=9090 + +# Specific address +ros2 launch ros_mcp_rosbridge.launch.py address:=127.0.0.1 +``` + +## Integration Patterns + +### How to add Rosbridge to Existing Robot + +#### Method 1: Include Rosbridge Launch File +```python +# Your existing robot launch file (e.g., my_robot.launch.py) +from launch import LaunchDescription +from launch.actions import IncludeLaunchDescription +from launch.launch_description_sources import PythonLaunchDescriptionSource +from launch_ros.actions import Node + +def generate_launch_description(): + # Your existing robot nodes + robot_node = Node( + package='my_robot_pkg', + executable='robot_node', + name='my_robot' + ) + + sensor_node = Node( + package='my_robot_pkg', + executable='sensor_node', + name='sensor_node' + ) + + # Include rosbridge for MCP communication + rosbridge_launch = IncludeLaunchDescription( + PythonLaunchDescriptionSource([ + 'ros_mcp_server', '/launch/ros_mcp_rosbridge.launch.py' + ]), + launch_arguments={ + 'port': '9090', + 'address': '', + 'log_level': 'info' + }.items() + ) + + return LaunchDescription([ + robot_node, + sensor_node, + rosbridge_launch, # Add this line + ]) +``` + +#### Method 2: Add Rosbridge Node Directly + +```python +# Add rosbridge node to your existing launch file +from launch.actions import DeclareLaunchArgument, LogInfo +from launch.substitutions import LaunchConfiguration +from launch_ros.actions import Node + +from launch import LaunchDescription + + +def generate_launch_description(): + """Generate the launch description for rosbridge only.""" + + # Declare launch arguments + port_arg = DeclareLaunchArgument( + "port", default_value="9090", description="Port for rosbridge websocket server" + ) + + address_arg = DeclareLaunchArgument( + "address", + default_value="", + description="Address for rosbridge websocket server (empty for all interfaces)", + ) + + log_level_arg = DeclareLaunchArgument( + "log_level", default_value="info", description="Log level for rosbridge server" + ) + + # Rosbridge websocket server node + rosbridge_node = Node( + package="rosbridge_server", + executable="rosbridge_websocket", + name="rosbridge_websocket", + output="screen", + parameters=[ + { + "port": LaunchConfiguration("port"), + "address": LaunchConfiguration("address"), + "use_compression": False, + "max_message_size": 10000000, + "send_action_goals_in_new_thread": True, + "call_services_in_new_thread": True, + "default_call_service_timeout": 5.0, + } + ], + arguments=["--ros-args", "--log-level", LaunchConfiguration("log_level")], + ) + + return LaunchDescription( + [ + port_arg, + address_arg, + log_level_arg, + rosbridge_node, + ] + ) +``` + + +#### Method 3: Separate Launch Files (Recommended) +```bash +# Terminal 1: Launch your robot +ros2 launch my_robot_pkg my_robot.launch.py + +# Terminal 2: Launch rosbridge for MCP +ros2 launch ros_mcp_server ros_mcp_rosbridge.launch.py port:=9090 +``` + +| Argument | Default | Description | +|----------|---------|-------------| +| `port` | 9090 | WebSocket server port | +| `address` | "" | Bind address (empty = all interfaces) | +| `log_level` | info | Log level (debug, info, warn, error) | \ No newline at end of file diff --git a/docs/restructuring_plan.md b/docs/restructuring_plan.md new file mode 100644 index 0000000..6c3f984 --- /dev/null +++ b/docs/restructuring_plan.md @@ -0,0 +1,183 @@ +# Repository Restructuring & Tool Migration Plan + +> **Note**: This plan documents the refactoring process that migrated the monolithic server to a modular structure. The refactoring is now complete. + +## Summary: Implementation vs Original Plan + +**Status**: โœ… **Phase 1 Complete** - All 31 tools migrated to modular structure + +**Key Differences from Original Plan:** +- โœ… Used `tools/__init__.py` instead of `tools.py` (better Python package convention) +- โœ… Used `main.py` instead of `server.py` (entry point naming) +- โš ๏ธ Function signature: `register_all_tools(mcp, ws_manager, ...)` takes `ws_manager` as parameter (more flexible than creating it internally) +- โœ… WebSocket manager renamed to `utils/websocket.py` (matches original plan structure) +- โœ… Helper functions in `tools/images.py` (co-located with usage, not separate `utils.py`) + +**Public API**: `from ros_mcp.tools import register_all_tools` - imports from `ros_mcp/tools/__init__.py` + +## Goal + +Refactor **ros-mcp-server** to be importable as a library, enabling integration into **simple-mcp-ai** (proprietary) using a git submodule approach. + +- **ros-mcp-server**: Apache 2.0 licensed, ROS MCP tools +- **simple-mcp-ai**: Proprietary, OAuth + Cloudflare tunnel infrastructure + +## Overview + +### Current State +- **Total tools**: 31 +- **Status**: โœ… **All tools migrated** (31/31) +- **Structure**: Modular structure with tools organized by category in `ros_mcp/tools/` + +### Tool Categories Overview + +| Category | File | Count | Tools | Status | +|----------|------|-------|-------|--------| +| Connection | `tools/connection.py` | 2 | connect_to_robot, ping_robot | โœ… Done | +| Robot Config | `tools/robot_config.py` | 3 | get_verified_robot_spec, get_verified_robots_list, detect_ros_version | โœ… Done | +| Topics | `tools/topics.py` | 10 | get_topics, get_topic_type, get_message_details, get_topic_publishers, get_topic_subscribers, inspect_all_topics, subscribe_once, publish_once, subscribe_for_duration, publish_for_durations | โœ… Done| +| Services | `tools/services.py` | 6 | get_services, get_service_type, get_service_details, get_service_providers, inspect_all_services, call_service | โœ… Done | +| Nodes | `tools/nodes.py` | 3 | get_nodes, get_node_details, inspect_all_nodes | โœ… Done | +| Parameters | `tools/parameters.py` | 7 | get_parameter, set_parameter, has_parameter, delete_parameter, get_parameters, inspect_all_parameters, get_parameter_details | โœ… Done | +| Actions | `tools/actions.py` | 7 | get_actions, get_action_type, get_action_details, get_action_status, inspect_all_actions, send_action_goal, cancel_action_goal | โœ… Done | +| Images | `tools/images.py` | 1 | view_saved_image | โœ… Done | +| Utils | `tools/images.py` | - | convert_expects_image_hint, _encode_image_to_imagecontent (helper functions in images.py) | โœ… Done | + +### Current Structure (Implemented) + +``` +ros-mcp-server/ +โ”œโ”€โ”€ ros_mcp/ # Package +โ”‚ โ”œโ”€โ”€ __init__.py +โ”‚ โ”œโ”€โ”€ main.py # MCP instance + main() โœ… +โ”‚ โ”œโ”€โ”€ tools/ # Tool implementations by category โœ… +โ”‚ โ”‚ โ”œโ”€โ”€ __init__.py # Main registration function (public API) โœ… +โ”‚ โ”‚ โ”œโ”€โ”€ connection.py # 2 tools โœ… +โ”‚ โ”‚ โ”œโ”€โ”€ robot_config.py # 3 tools โœ… +โ”‚ โ”‚ โ”œโ”€โ”€ topics.py # 10 tools โœ… +โ”‚ โ”‚ โ”œโ”€โ”€ services.py # 6 tools โœ… +โ”‚ โ”‚ โ”œโ”€โ”€ nodes.py # 3 tools โœ… +โ”‚ โ”‚ โ”œโ”€โ”€ parameters.py # 7 tools โœ… +โ”‚ โ”‚ โ”œโ”€โ”€ actions.py # 7 tools โœ… +โ”‚ โ”‚ โ””โ”€โ”€ images.py # 1 tool + helper functions โœ… +โ”‚ โ””โ”€โ”€ utils/ # Utility modules โœ… +โ”‚ โ”œโ”€โ”€ config_utils.py +โ”‚ โ”œโ”€โ”€ network_utils.py +โ”‚ โ””โ”€โ”€ websocket.py # WebSocket manager (renamed from websocket_manager.py) +โ”œโ”€โ”€ server.py # Entry point: from ros_mcp.main import main โœ… +โ””โ”€โ”€ pyproject.toml +``` + +**Public API**: `from ros_mcp.tools import register_all_tools` (imports from `ros_mcp/tools/__init__.py`) + +## Phase 1: Refactor ros-mcp-server (Tool Migration) โœ… COMPLETE + +### Migration Pattern + +For each tool category: +1. **Extract implementation**: Create `tool_name_impl()` function in appropriate module +2. **Create registration function**: Each module exports `register__tools(mcp, ws_manager, ...)` +3. **Update main registration**: Import and call in `ros_mcp/tools/__init__.py` +4. **Remove from server.py**: Delete `@mcp.tool` decorated function + +### Tool Categories (All Complete โœ…) + +- **Connection** (2 tools): `connect_to_robot`, `ping_robot` +- **Robot Config** (3 tools): `get_verified_robot_spec`, `get_verified_robots_list`, `detect_ros_version` +- **Topics** (10 tools): `get_topics`, `get_topic_type`, `get_message_details`, `get_topic_publishers`, `get_topic_subscribers`, `inspect_all_topics`, `subscribe_once`, `publish_once`, `subscribe_for_duration`, `publish_for_durations` +- **Services** (6 tools): `get_services`, `get_service_type`, `get_service_details`, `get_service_providers`, `inspect_all_services`, `call_service` +- **Nodes** (3 tools): `get_nodes`, `get_node_details`, `inspect_all_nodes` +- **Parameters** (7 tools): `get_parameter`, `set_parameter`, `has_parameter`, `delete_parameter`, `get_parameters`, `inspect_all_parameters`, `get_parameter_details` +- **Actions** (7 tools): `get_actions`, `get_action_type`, `get_action_details`, `get_action_status`, `inspect_all_actions`, `send_action_goal`, `cancel_action_goal` +- **Images** (1 tool): `view_saved_image` + helper functions (`convert_expects_image_hint`, `_encode_image_to_imagecontent`) + +### Main Registration Function โœ… + +**File**: `ros_mcp/tools/__init__.py` + +The public API function `register_all_tools()` registers all 31 tools: + +```python +def register_all_tools( + mcp: FastMCP, + ws_manager: WebSocketManager, + rosbridge_ip: str = "127.0.0.1", + rosbridge_port: int = 9090, +) -> None: + """Register all ROS MCP tools with the provided FastMCP instance.""" + # Registers all tool categories... +``` + +**Note**: Function signature differs from original plan - takes `ws_manager` as parameter (more flexible than creating it internally). + +## Phase 2: Integration into simple-mcp-ai โณ Pending + +**Note**: This phase is for the **simple-mcp-ai** repository, not ros-mcp-server. + +### Integration Steps + +1. **Add git submodule**: + ```bash + cd simple-mcp-ai + git submodule add https://github.com/robotmcp/ros-mcp-server.git + ``` + +2. **Create `ros_integration.py`**: + ```python + import sys, os + sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'ros-mcp-server')) + from ros_mcp.tools import register_all_tools + from ros_mcp.utils.websocket import WebSocketManager + ``` + +3. **Update `main.py`**: + ```python + from fastmcp import FastMCP + from ros_integration import register_all_tools, WebSocketManager + + mcp = FastMCP("simple-mcp-ai") + ws_manager = WebSocketManager("127.0.0.1", 9090, default_timeout=5.0) + register_all_tools(mcp, ws_manager, rosbridge_ip="127.0.0.1", rosbridge_port=9090) + ``` + +4. **Update `requirements.txt`** with ros-mcp dependencies + +5. **Delete old `tools.py`** (if exists) + +## Migration Checklist + +### Phase 1: Tool Migration โœ… COMPLETE + +- [X] Create `ros_mcp/tools/` directory structure โœ… +- [X] Move helper functions (in `tools/images.py`) โœ… +- [X] Move all 31 tools across 8 categories โœ… +- [X] Update `ros_mcp/tools/__init__.py` registration function โœ… +- [X] Update `server.py` entry point โœ… + +## Verification Checklist + +### Phase 1 (Tool Migration) โœ… + +- [X] All 31 tools registered in `register_all_tools()` +- [X] Each category has its own module file +- [X] Helper functions in `tools/images.py` +- [X] `ros-mcp-server` works standalone + +### Phase 2 (Integration) โณ Pending + +- [ ] Submodule added to simple-mcp-ai +- [ ] `ros_integration.py` created +- [ ] `main.py` updated to use `register_all_tools()` +- [ ] Dependencies updated +- [ ] Old `tools.py` removed +- [ ] Integration tested end-to-end + +## Benefits + +- โœ… Clean licensing separation (submodule stays Apache 2.0) +- โœ… Easy updates: `git submodule update --remote` +- โœ… Single MCP instance with all tools +- โœ… ros-mcp-server works standalone +- โœ… Well-organized, maintainable code structure +- โœ… Clear separation of concerns +- โœ… Easy to extend with new tools diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..f5fbe91 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,415 @@ +# Testing Guide for ROS MCP Server + +This guide explains how to test the ROS MCP Server using prompts, resources, and automated tests. + +## Running Installation Tests + +Installation tests verify that the package can be installed correctly using different methods (uvx, pip, uv). These tests use Docker to create clean Python environments and install from git. + +### Prerequisites + +- Docker installed and running +- pytest (`pip install pytest pytest-timeout`) + +### Running Installation Tests + +```bash +# Run all installation tests (uses current branch by default) +pytest tests/installation -v + +# Test a specific branch +pytest tests/installation -v --branch=feat/new-feature + +# Test a specific tag/release +pytest tests/installation -v --branch=v2.5.0 + +# Test from a different repository (e.g., a fork) +pytest tests/installation -v --repo-url=https://github.com/user/fork.git --branch=main + +# Run only uvx installation tests +pytest tests/installation/test_uvx_install.py -v + +# Run only pip installation tests +pytest tests/installation/test_pip_install.py -v +``` + +### Installation Test Categories + +| Test File | What It Tests | +|-----------|---------------| +| `test_uvx_install.py` | `uvx --from git+URL@branch ros-mcp` installation | +| `test_pip_install.py` | `pip install git+URL@branch` and `pip install .` | +| `test_source_install.py` | `uv sync` development workflow | + +### Multi-Python Version Testing + +Installation tests run against Python 3.10, 3.11, and 3.12: + +```bash +# These are parametrized tests that run automatically +pytest tests/installation/test_pip_install.py::test_pip_install_python_versions -v +pytest tests/installation/test_source_install.py::test_uv_source_python_versions -v +``` + +## Prerequisites + +Before testing, ensure you have: + +1. **ROS installed** (ROS 1 or ROS 2) + - ROS 2: `which ros2` + - ROS 1: TODO + +2. **Turtlesim package installed** + - ROS 2: `ros2 pkg list | grep turtlesim` + - ROS 1: TODO + +3. **Rosbridge server installed** + - ROS 2: `ros2 pkg list | grep rosbridge` + - ROS 1: TODO + +4. **MCP Server running** + - The ROS MCP Server should be configured and running in your MCP client (e.g., Cursor, Claude) + +## Setting Up the Test Environment + +### Step 1: Start ROS Core (if not already running) + + +```bash +# Terminal 1: Start ROS 2 daemon +ros2 daemon start +``` + + + + +### Step 2: Start Turtlesim + + +```bash +# Terminal 2: Start turtlesim node +ros2 run turtlesim turtlesim_node +``` + + + +### Step 3: Start Rosbridge + + +```bash +# Terminal 3: Start rosbridge WebSocket server +ros2 run rosbridge_server rosbridge_websocket +# or +ros2 launch rosbridge_server rosbridge_websocket_launch.xml +``` + + + +The rosbridge server will start on `ws://localhost:9090` by default. + +## Using Prompts for Testing + +Prompts are interactive guides that provide step-by-step instructions for testing specific tool categories. They are accessed through the MCP prompt interface. + +### Available Test Prompts + +1. **`test-server-tools`** - High-level overview of all ROS MCP Server tools +2. **`test-connection-tools`** - Connection and ROS version detection tools +3. **`test-topics-tools`** - Topic discovery, subscription, and publishing tools +4. **`test-services-tools`** - Service discovery and calling tools +5. **`test-nodes-tools`** - Node discovery and inspection tools +6. **`test-parameters-tools`** - Parameter tools (ROS 2 only) +7. **`test-actions-tools`** - Action tools (ROS 2 only) + +### How to Use Prompts + +1. **Access prompts through your MCP client:** + - Use the prompt selector or command palette (if available) or simply prompt `test server tools` + - Prompts are registered with names like `test-server-tools`, `test-topics-tools`, etc. + +2. **Follow the prompt instructions:** + - Each prompt provides detailed testing steps + - Includes tool usage examples + - Contains troubleshooting information + +3. **Example workflow with prompts:** + ``` + 1. Start by prompting `test server tools` or selecting te template prompts for an overview + 2. Use `test connection tools` to establish connection and test the setup + 3. Use category-specific prompts (e.g., `test topics- tools`) for detailed testing + ``` + +### Using Resources for Testing + +Resources provide comprehensive system information in JSON format. They are accessed through the MCP resource interface. + +### Available Resources + +#### ROS Metadata Resources + +1. **`ros-mcp://ros-metadata/all`** + - Complete system overview + - Includes topics, services, nodes, parameters, and ROS version + - Useful for getting a snapshot of the entire ROS system + +2. **`ros-mcp://ros-metadata/topics/all`** + - All topics with their types, publishers, and subscribers + - Comprehensive topic connection information + +3. **`ros-mcp://ros-metadata/services/all`** + - All services with their types and providers + - Complete service discovery information + +4. **`ros-mcp://ros-metadata/nodes/all`** + - All nodes with their publishers, subscribers, and services + - Detailed node connection information + +5. **`ros-mcp://ros-metadata/actions/all`** (ROS 2 only) + - All actions with their types + - Action discovery information + +#### Robot Specification Resources + +6. **`ros-mcp://robot-specs/get_verified_robots_list`** + - List of available robot specifications + - Returns JSON with robot names from `robot_specifications/` directory + +### How to Use Resources + +1. **Request resources through your AI assistant:** + - Simply ask: `get ros-metadata resources` or `get topics resource`, or `get services resource`, + - Ask to access specific resources: `access ros-mcp://ros-metadata/all` etc. + - The AI assistant will access the resource and return the JSON data + +2. **Alternative: Access through MCP client interface:** + - Resources are accessed via URI (e.g., `ros-mcp://ros-metadata/all`) + - Your MCP client may provide a resource browser or URI access + - Check your MCP client's documentation for resource access methods + +3. **Parse the JSON response:** + - Resources return JSON strings + - The AI assistant can parse and explain the data for you + +### Example: Using Resources + +**Get complete system overview:** +``` +Access: ros-mcp://ros-metadata/all + +Returns JSON with: +{ + "topics": [...], + "services": [...], + "nodes": [...], + "parameters": [...], + "ros_version": "ROS 2" +} +``` + +**Get all topic details:** +``` +Access: ros-mcp://ros-metadata/topics/all + +Returns JSON with: +{ + "total_topics": 5, + "topics": { + "/turtle1/cmd_vel": { + "type": "geometry_msgs/msg/Twist", + "publishers": [...], + "subscribers": [...] + }, + ... + } +} +``` + +## Complete Tools Testing Workflow + +### 1. Initial Setup and Connection + +```python +# Step 1: Connect to ROS system +connect_to_robot(ip='127.0.0.1', port=9090) + +# Step 2: Verify connection and detect ROS version +detect_ros_version() +``` + +### 2. Discovery Phase + +**Using Tools:** +```python +# Discover what's available +get_topics() +get_services() +get_nodes() +get_actions() # ROS 2 only +``` + +**Using Resources:** +``` +Access: ros-mcp://ros-metadata/all +``` + +### 3. Detailed Testing by Category + +**Topics:** +```python +# Get topic details +get_topic_details('/turtle1/cmd_vel') + +# Subscribe to a topic +subscribe_once(topic='/turtle1/pose', msg_type='turtlesim/msg/Pose') + +# Publish to a topic +publish_once( + topic='/turtle1/cmd_vel', + msg_type='geometry_msgs/msg/Twist', + msg={'linear': {'x': 2.0, 'y': 0.0, 'z': 0.0}} +) +``` + +**Services:** +```python +# Get service details +get_service_details('/turtle1/teleport_absolute') + +# Call a service +call_service( + service_name='/turtle1/teleport_absolute', + service_type='turtlesim/srv/TeleportAbsolute', + request={'x': 5.5, 'y': 5.5, 'theta': 0.0} +) +``` + +**Nodes:** +```python +# Get node details +get_node_details('/turtlesim') +``` + +**Parameters (ROS 2 only):** +```python +# Get parameters +get_parameters('turtlesim') + +# Set parameter +set_parameter('/turtlesim:background_r', '255') +``` + +**Actions (ROS 2 only):** +```python +# Get action details +get_action_details('/turtle1/rotate_absolute') + +# Send action goal +send_action_goal( + action_name='/turtle1/rotate_absolute', + action_type='turtlesim/action/RotateAbsolute', + goal={'theta': 1.57} +) +``` + +### 4. Gather Resources + +Access resources to get comprehensive system information: + +``` +1. Access ros-mcp://ros-metadata/topics/all + - Get all topics with types, publishers, and subscribers + +2. Access ros-mcp://ros-metadata/services/all + - Get all services with types and providers + +3. Access ros-mcp://ros-metadata/nodes/all + - Get all nodes with publishers, subscribers, and services + +4. Access ros-mcp://ros-metadata/actions/all (ROS 2 only) + - Get all actions with their types + +5. Access ros-mcp://ros-metadata/all + - Get complete system overview +``` + +## Testing Checklist + +- [ ] Prerequisites installed and verified +- [ ] ROS core/daemon running +- [ ] Turtlesim node running +- [ ] Rosbridge server running +- [ ] MCP server connected successfully +- [ ] ROS version detected correctly +- [ ] All discovery tools working (topics, services, nodes, actions) +- [ ] Category-specific tools tested (topics, services, nodes, parameters, actions) +- [ ] Resources accessible and returning valid JSON + +## Troubleshooting + +### Connection Issues + +- **Cannot connect to rosbridge:** + - Verify rosbridge is running: Check terminal output + - Check port: Default is 9090 + - Verify IP address: Use `127.0.0.1` for localhost + +- **ROS version detection fails:** + - Ensure ROS environment is sourced + - For ROS 2: `source /opt/ros//setup.bash` + - For ROS 1: `source /opt/ros//setup.bash` + +### Tool Issues + +- **Tools return empty results:** + - Verify `turtlesim` is running + - Check that topics/services/nodes exist + - Ensure rosbridge is connected + +- **Service calls fail:** + - Verify service exists: `get_services()` + - Check service type: `get_service_details()` + - Verify request format matches service definition + +### Resource Issues + +- **Resources return errors:** + - Verify connection to ROS system + - Check that rosbridge is running + - Ensure required ROS services are available + +## Next Steps + +After completing basic testing: + +1. **Explore category-specific prompts:** + - Use `test-topics-tools` for detailed topic testing + - Use `test-services-tools` for service testing + - Use other category prompts as needed + +2. **Test with your own robot:** + - Replace turtlesim with your robot's nodes + - Test with your robot's topics, services, and actions + - Gather resources to understand your system structure + +3. **Integrate with your workflow:** + - Use prompts for guided testing + - Use resources for system monitoring + - Combine tools and resources for comprehensive testing + +## Additional Resources + +- **Launch System Guide:** See `docs/launch_system.md` for integrating rosbridge with your robot +- **Installation Guide:** See `docs/install/installation.md` for setup instructions +- **Category-Specific Prompts:** Access detailed testing guides for each tool category + diff --git a/examples/1_turtlesim/README.md b/examples/1_turtlesim/README.md new file mode 100644 index 0000000..7949366 --- /dev/null +++ b/examples/1_turtlesim/README.md @@ -0,0 +1,349 @@ +# Tutorial - Getting Started with ROS MCP Server and Turtlesim + +Welcome to your first steps with the ROS MCP Server! This tutorial will guide you through using the ROS MCP Server with Turtlesim, the perfect "Hello World" robot for learning ROS integration. + +Turtlesim is a lightweight simulator that demonstrates the fundamental concepts of ROS at the most basic level. It's ideal for understanding how the MCP server can interact with ROS systems before moving on to more complex robots. + +## What You'll Learn + +By the end of this tutorial, you'll be able to: +- Launch Turtlesim on your ROS system +- Explore ROS topics and services +- Control the turtle using natural language commands through the MCP server +- Understand the basic concepts of ROS-MCP integration + +## Prerequisites + +Before starting this tutorial, make sure you have: + +โœ… **Any version of ROS installed** (ROS1 Noetic, ROS2 Humble, or ROS2 Jazzy) +โœ… **Basic familiarity with terminal/command line** +โœ… **The ROS MCP Server installed** (see [Installation Guide](../../docs/install/installation.md) for setup instructions) + +> ๐Ÿ’ก **Tip**: If you don't have ROS installed yet, you can use our [Docker Turtlesim example](../5_docker_turtlesim/). However, we recommend this option only for users who are familiar with Docker and X11 forwarding settings. (We would like you to spend time exploring the Robot MCP server, not figuring out X11 forwarding on your machine!) + +## Step 1: Launch Turtlesim + +First, let's get Turtlesim running on your system. The exact command depends on your ROS version: + +
+ROS1 (e.g., Noetic) + +### Launch Turtlesim + +1. **Source your ROS environment:** + ```bash + source /opt/ros/noetic/setup.bash # or /opt/ros//setup.bash + ``` + +2. **Launch Turtlesim:** + ```bash + rosrun turtlesim turtlesim_node + ``` + +You should see a window appear with a turtle in the center of a blue background. + +### Troubleshooting ROS1 +- If you get "command not found", make sure you've sourced the ROS environment +- If the window doesn't appear, check your display settings (especially on WSL or remote connections) + +
+ +
+ROS2 (e.g., Humble, Jazzy) + +### Launch Turtlesim + +1. **Source your ROS2 environment:** + ```bash + source /opt/ros/humble/setup.bash # or /opt/ros/jazzy/setup.bash + ``` + +2. **Launch Turtlesim:** + ```bash + ros2 run turtlesim turtlesim_node + ``` + +You should see a window appear with a turtle in the center of a blue background. + +### Troubleshooting ROS2 +- If you get "command not found", make sure you've sourced the ROS2 environment +- If the window doesn't appear, check your display settings (especially on WSL or remote connections) + +
+ +## Step 2: Explore ROS Topics and Services + +Now that Turtlesim is running, let's explore what's available in the ROS system. Open a new terminal and source your ROS environment, then try these commands: + +### List Available Topics + +
+ROS1 (e.g., Noetic) + +```bash +# Source ROS environment +source /opt/ros/noetic/setup.bash + +# List all topics +rostopic list + +# Monitor turtle position +rostopic echo /turtle1/pose + +# Monitor velocity commands +rostopic echo /turtle1/cmd_vel +``` + +
+ +
+ROS2 (e.g., Humble, Jazzy) + +```bash +# Source ROS2 environment (adjust for your version) +source /opt/ros/humble/setup.bash # or /opt/ros/jazzy/setup.bash + +# List all topics +ros2 topic list + +# Monitor turtle position +ros2 topic echo /turtle1/pose + +# Monitor velocity commands +ros2 topic echo /turtle1/cmd_vel +``` + +
+ +### List Available Services + +
+ROS1 (e.g., Noetic) + +```bash +# List all services +rosservice list + +# Get information about a specific service +rosservice info /turtle1/set_pen +``` + +
+ +
+ROS2 (e.g., Humble, Jazzy) + +```bash +# List all services +ros2 service list + +# Get information about a specific service +ros2 service type /turtle1/set_pen +``` + +
+ +### Understanding Topics and Services + +- **Topics** are like radio stations - nodes can publish data to topics and subscribe to receive data +- **Services** are like function calls - you can request a specific action and get a response +- The turtle's position is published on `/turtle1/pose` +- Movement commands are sent via `/turtle1/cmd_vel` +- Services like `/turtle1/set_pen` can change the turtle's drawing properties + +## Step 3: Install and Configure the MCP Server + +If you haven't already set up the ROS MCP Server, follow the detailed [Installation Guide](../../docs/install/installation.md). The MCP server can run on: + +- **Same machine** as your ROS system (simplest setup) +- **Different machine** on the same local network (for remote control) + +The installation guide covers: +- Installing the MCP server +- Configuring your language model client (Claude Desktop, etc.) +- Setting up rosbridge for communication + +## Step 4: Hands-on Exploration with MCP Server + +Now for the fun part! Once your MCP server is connected, you can control the turtle using natural language. Here are some commands to try: + +### ๐Ÿš€ Basic Movement Commands + +Try these natural language commands with your AI assistant: + +``` +Move the turtle forward +``` + +``` +Turn the turtle left +``` + +``` +Make the turtle go backward +``` + +``` +Stop the turtle +``` + +### ๐Ÿ“Š Information Queries + +Ask your AI assistant about the robot's state: + +``` +Tell me about this robot. +``` + + +``` +What topics and services are available on the robot? +``` + +``` +What is the turtle's current position? +``` + + + +### ๐ŸŽจ Setup Commands + + +``` +Reset the turtle to the center +``` + +``` +Change the turtle's pen color to red +``` + +``` +Spawn a new turtle +``` + +``` +Clear the background +``` + +### ๐ŸŽฏ Advanced Commands + +Try more complex behaviors: + +``` +Draw a square with the turtle +``` + +``` +Move the turtle to position (5, 5) +``` + +``` +Make the turtle follow a circular path +``` + +``` +Draw a spiral pattern +``` + +### ๐Ÿ’ก Pro Tips + +- **Be specific**: Instead of "move", try "move forward at 2 m/s" +- **Ask questions**: "What can this robot do?" or "How do I make the turtle draw?" +- **Experiment**: Try combining commands like "draw a square, then change the pen color to green" +- **Monitor**: Use `rostopic echo` in a separate terminal to see the commands being sent + +## Troubleshooting + +### Common Issues + +
+MCP Server Connection Issues + +**Problem**: AI assistant can't connect to the robot + +**Solutions**: +- Verify rosbridge is running: `ros2 launch rosbridge_server rosbridge_websocket_launch.xml` +- Check if MCP server is running and connected +- Ensure firewall allows WebSocket connections (port 9090) +- For remote connections, verify the robot's IP address + +
+ +
+ROS Environment Issues + +**Problem**: "command not found" errors + +**Solutions**: +- Always source your ROS environment: `source /opt/ros/[version]/setup.bash` +- Add sourcing to your `.bashrc` for automatic setup +- Verify ROS installation with `rosversion -d` (ROS1) or `ros2 doctor` (ROS2) + +
+ +
+Display Issues + +**Problem**: Turtlesim window doesn't appear + +**Solutions**: +- **WSL users**: Install X11 forwarding: `sudo apt install x11-apps` +- **Remote connections**: Use X11 forwarding: `ssh -X username@hostname` +- **Docker users**: Check X11 forwarding configuration + +
+ +
+Permission Issues + +**Problem**: Can't control the turtle + +**Solutions**: +- Ensure you're not running multiple turtlesim instances +- Check if another process is controlling the turtle +- Restart turtlesim if commands aren't working + +
+ +### ๐Ÿ’ก Still Stuck? + +If you're still having issues, check the official documentation for your ROS version: + +- **[ROS1 Noetic Installation](https://wiki.ros.org/noetic/Installation)** | **[Turtlesim Wiki](https://wiki.ros.org/turtlesim)** +- **[ROS2 Humble Installation](https://docs.ros.org/en/humble/Installation.html)** | **[Turtlesim Tutorial](https://docs.ros.org/en/humble/Tutorials/Beginner-CLI-Tools/Introducing-Turtlesim/Introducing-Turtlesim.html)** +- **[ROS2 Jazzy Installation](https://docs.ros.org/en/jazzy/Installation.html)** | **[Turtlesim Tutorial](https://docs.ros.org/en/jazzy/Tutorials/Beginner-CLI-Tools/Introducing-Turtlesim/Introducing-Turtlesim.html)** + +**Additional Resources:** +- **[ROS Answers](https://answers.ros.org/)** - Community Q&A for specific problems +- **ROS2 Doctor**: Run `ros2 doctor --report` to diagnose ROS2 installation issues +- **[ROS Troubleshooting Guide](https://wiki.ros.org/ROS/Troubleshooting)** (ROS1) + +## Next Steps + +Congratulations! You've successfully controlled a robot using natural language. Here's what you can explore next: + +### ๐ŸŽฏ Immediate Next Steps +1. **Try more complex patterns**: Draw shapes, follow paths, create animations +2. **Experiment with services**: Change colors, spawn multiple turtles, modify the environment +3. **Monitor the system**: Use `rostopic echo` to see the data flowing through ROS + +### ๐Ÿš€ Advanced Exploration +1. **Explore other examples** in this repository: + - [Gemini Integration](../2_gemini/) - Advanced AI integration + - [Limo Mobile Robot](../3_limo_mobile_robot/) - Real robot control + - [Unitree Go2](../4_unitree_go2/) - Quadruped robot + +2. **Connect to real robots**: Use the same MCP server with physical robots +3. **Integrate with other tools**: Combine with computer vision, planning algorithms, etc. + +### ๐Ÿ“š Learning Resources +- [ROS Documentation](https://docs.ros.org/) +- [Turtlesim Tutorials](https://docs.ros.org/en/humble/Tutorials/Beginner-CLI-Tools/Introducing-Turtlesim/Introducing-Turtlesim.html) +- [MCP Protocol Documentation](https://modelcontextprotocol.io/) + +--- + +**Happy robot controlling!** ๐Ÿค–โœจ + +This tutorial has shown you the fundamentals of ROS-MCP integration. The same principles apply to more complex robots - you're now ready to explore the exciting world of natural language robot control! diff --git a/examples/1_turtlesim/ros_mcp_turtlesim.launch.py b/examples/1_turtlesim/ros_mcp_turtlesim.launch.py new file mode 100644 index 0000000..f0ab9e2 --- /dev/null +++ b/examples/1_turtlesim/ros_mcp_turtlesim.launch.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 + +""" +ROS2 Launch file for ROS-MCP Server +Replaces the functionality of scripts/launch_ros.sh with proper ROS2 launch system. + +This launch file starts: +- rosbridge_websocket server +- turtlesim node +- Provides proper process management and cleanup +""" + +from launch.actions import DeclareLaunchArgument, LogInfo +from launch.substitutions import LaunchConfiguration +from launch_ros.actions import Node + +from launch import LaunchDescription + + +def generate_launch_description(): + """Generate the launch description for ROS-MCP Server.""" + + # Declare launch arguments + rosbridge_port_arg = DeclareLaunchArgument( + "port", default_value="9090", description="Port for rosbridge websocket server" + ) + + rosbridge_address_arg = DeclareLaunchArgument( + "address", + default_value="", + description="Address for rosbridge websocket server (empty for all interfaces)", + ) + + turtlesim_name_arg = DeclareLaunchArgument( + "turtlesim_name", default_value="turtlesim", description="Name for the turtlesim node" + ) + + # Rosbridge websocket server node + rosbridge_node = Node( + package="rosbridge_server", + executable="rosbridge_websocket", + name="rosbridge_websocket", + output="screen", + parameters=[ + { + "port": LaunchConfiguration("port"), + "address": LaunchConfiguration("address"), + "use_compression": False, + "max_message_size": 10000000, + "send_action_goals_in_new_thread": True, + "call_services_in_new_thread": True, + } + ], + arguments=["--ros-args", "--log-level", "info"], + ) + + # Turtlesim node + turtlesim_node = Node( + package="turtlesim", + executable="turtlesim_node", + name=LaunchConfiguration("turtlesim_name"), + output="screen", + arguments=["--ros-args", "--log-level", "info"], + ) + + # Log info about what's being launched + log_info = LogInfo( + msg=[ + "Starting ROS-MCP Server with:", + " - Rosbridge WebSocket on port: ", + LaunchConfiguration("port"), + " - Turtlesim node: ", + LaunchConfiguration("turtlesim_name"), + ] + ) + + return LaunchDescription( + [ + rosbridge_port_arg, + rosbridge_address_arg, + turtlesim_name_arg, + log_info, + rosbridge_node, + turtlesim_node, + ] + ) diff --git a/examples/2_gemini/README.md b/examples/2_gemini/README.md new file mode 100644 index 0000000..7e64ce0 --- /dev/null +++ b/examples/2_gemini/README.md @@ -0,0 +1,39 @@ +# Demo, Gemini-CLI with ROS-MCP-Server + +## Prerequisite +- Installation of Gemini-CLI. [https://github.com/google-gemini/gemini-cli] +- Installation of ROS or ROS2. Test if ROS is installed by running Turtlesim. If you are not sure, follow this tutorial [https://wiki.ros.org/ROS/Tutorials] +- Installation of ROS-MCP-Server except Section II (installation/settings of Claude desktop) [Installation Guide](../../docs/install/installation.md). For Gemini CLI-specific setup, see the [Gemini CLI setup guide](../../docs/install/clients/gemini-cli.md) + +## Update Gemini CLI MCP settings + +- Open `settings.json` file of Gemini CLI, located `~/.gemini/settings.json` +- Add "mcpServer" setting below in the `settings.json` +```json +{ + "mcpServers": { + "ros-mcp-server": { + "command": "uv", + "args": [ + "--directory", + "//ros-mcp-server", + "run", + "server.py" + ] + } + } +} +``` + +## Demo Environment + +- Ubuntu 20.04 +- ROS Noetic + +> **Compatibility note** +> The demo environment below was validated on Ubuntu 20.04 + ROS Noetic. +> Although the prerequisites mention ROS or ROS2, users running ROS2 Jazzy / WSL may need a different setup path and should verify `turtlesim`, `rosbridge_websocket` (port 9090), and MCP server connectivity separately. + +## Demo Video + +[![Gemini Demo with ROS MCP Server](./image/gemini-demo-ros-mcp.jpeg)](https://youtu.be/UAEG0qTADDk) diff --git a/examples/2_gemini/image/gemini-demo-ros-mcp.jpeg b/examples/2_gemini/image/gemini-demo-ros-mcp.jpeg new file mode 100644 index 0000000..23677c9 Binary files /dev/null and b/examples/2_gemini/image/gemini-demo-ros-mcp.jpeg differ diff --git a/examples/3_limo_mobile_robot/images/isaac_sim_check_extension.png b/examples/3_limo_mobile_robot/images/isaac_sim_check_extension.png new file mode 100644 index 0000000..5326728 Binary files /dev/null and b/examples/3_limo_mobile_robot/images/isaac_sim_check_extension.png differ diff --git a/examples/3_limo_mobile_robot/images/isaac_sim_installation.png b/examples/3_limo_mobile_robot/images/isaac_sim_installation.png new file mode 100644 index 0000000..ce862fa Binary files /dev/null and b/examples/3_limo_mobile_robot/images/isaac_sim_installation.png differ diff --git a/examples/3_limo_mobile_robot/images/isaac_sim_streaming.png b/examples/3_limo_mobile_robot/images/isaac_sim_streaming.png new file mode 100644 index 0000000..2830d9d Binary files /dev/null and b/examples/3_limo_mobile_robot/images/isaac_sim_streaming.png differ diff --git a/examples/3_limo_mobile_robot/images/isaac_sim_webrtc_streaming_client.png b/examples/3_limo_mobile_robot/images/isaac_sim_webrtc_streaming_client.png new file mode 100644 index 0000000..cacee01 Binary files /dev/null and b/examples/3_limo_mobile_robot/images/isaac_sim_webrtc_streaming_client.png differ diff --git a/examples/3_limo_mobile_robot/images/isaac_sim_webrtc_streaming_client_version.png b/examples/3_limo_mobile_robot/images/isaac_sim_webrtc_streaming_client_version.png new file mode 100644 index 0000000..77d6815 Binary files /dev/null and b/examples/3_limo_mobile_robot/images/isaac_sim_webrtc_streaming_client_version.png differ diff --git a/examples/3_limo_mobile_robot/images/limo.png b/examples/3_limo_mobile_robot/images/limo.png new file mode 100644 index 0000000..da64334 Binary files /dev/null and b/examples/3_limo_mobile_robot/images/limo.png differ diff --git a/examples/3_limo_mobile_robot/images/limo_isaac_sim.png b/examples/3_limo_mobile_robot/images/limo_isaac_sim.png new file mode 100644 index 0000000..fbf322d Binary files /dev/null and b/examples/3_limo_mobile_robot/images/limo_isaac_sim.png differ diff --git a/examples/3_limo_mobile_robot/images/limo_isaac_sim_check_topic.png b/examples/3_limo_mobile_robot/images/limo_isaac_sim_check_topic.png new file mode 100644 index 0000000..8138f8e Binary files /dev/null and b/examples/3_limo_mobile_robot/images/limo_isaac_sim_check_topic.png differ diff --git a/examples/3_limo_mobile_robot/images/limo_isaac_sim_connect.png b/examples/3_limo_mobile_robot/images/limo_isaac_sim_connect.png new file mode 100644 index 0000000..f24ff33 Binary files /dev/null and b/examples/3_limo_mobile_robot/images/limo_isaac_sim_connect.png differ diff --git a/examples/3_limo_mobile_robot/images/limo_isaac_sim_simple_movement.gif b/examples/3_limo_mobile_robot/images/limo_isaac_sim_simple_movement.gif new file mode 100644 index 0000000..bd29581 Binary files /dev/null and b/examples/3_limo_mobile_robot/images/limo_isaac_sim_simple_movement.gif differ diff --git a/examples/3_limo_mobile_robot/images/limo_isaac_sim_topic_list.png b/examples/3_limo_mobile_robot/images/limo_isaac_sim_topic_list.png new file mode 100644 index 0000000..f1838c7 Binary files /dev/null and b/examples/3_limo_mobile_robot/images/limo_isaac_sim_topic_list.png differ diff --git a/examples/3_limo_mobile_robot/images/limo_isaac_sim_usd_path.png b/examples/3_limo_mobile_robot/images/limo_isaac_sim_usd_path.png new file mode 100644 index 0000000..eec6a7d Binary files /dev/null and b/examples/3_limo_mobile_robot/images/limo_isaac_sim_usd_path.png differ diff --git a/examples/3_limo_mobile_robot/images/limo_real_connect.png b/examples/3_limo_mobile_robot/images/limo_real_connect.png new file mode 100644 index 0000000..c26d47c Binary files /dev/null and b/examples/3_limo_mobile_robot/images/limo_real_connect.png differ diff --git a/examples/3_limo_mobile_robot/images/limo_real_framework.png b/examples/3_limo_mobile_robot/images/limo_real_framework.png new file mode 100644 index 0000000..0bc378f Binary files /dev/null and b/examples/3_limo_mobile_robot/images/limo_real_framework.png differ diff --git a/examples/3_limo_mobile_robot/images/limo_real_simple_movement.gif b/examples/3_limo_mobile_robot/images/limo_real_simple_movement.gif new file mode 100644 index 0000000..6adb1db Binary files /dev/null and b/examples/3_limo_mobile_robot/images/limo_real_simple_movement.gif differ diff --git a/examples/3_limo_mobile_robot/images/limo_real_simple_movement.png b/examples/3_limo_mobile_robot/images/limo_real_simple_movement.png new file mode 100644 index 0000000..f59e4aa Binary files /dev/null and b/examples/3_limo_mobile_robot/images/limo_real_simple_movement.png differ diff --git a/examples/3_limo_mobile_robot/images/limo_real_whatcanyoudo1.png b/examples/3_limo_mobile_robot/images/limo_real_whatcanyoudo1.png new file mode 100644 index 0000000..aec163e Binary files /dev/null and b/examples/3_limo_mobile_robot/images/limo_real_whatcanyoudo1.png differ diff --git a/examples/3_limo_mobile_robot/images/limo_real_whatcanyoudo2.png b/examples/3_limo_mobile_robot/images/limo_real_whatcanyoudo2.png new file mode 100644 index 0000000..8e0f3e7 Binary files /dev/null and b/examples/3_limo_mobile_robot/images/limo_real_whatcanyoudo2.png differ diff --git a/examples/3_limo_mobile_robot/isaac_sim/README.md b/examples/3_limo_mobile_robot/isaac_sim/README.md new file mode 100644 index 0000000..8edc18a --- /dev/null +++ b/examples/3_limo_mobile_robot/isaac_sim/README.md @@ -0,0 +1,247 @@ +# Example - LIMO (Isaac Sim) +![Static Badge](https://img.shields.io/badge/ROS2-Available-green) + +Here is an introduction to the ROS MCP serverโ€™s capabilities using the robot simulator Isaac Sim with the mobile robot LIMO! + +This example includes the Isaac Sim Docker installation and execution, as well as a USD file with the LIMO robot equipped with a camera and LiDAR sensor. This setup allows you to easily configure the LIMO robot environment within Isaac Sim. + +## System Requirements + This example requires a PC that meets the minimum specifications to run Isaac Sim. Please refer to the [System Requirements](https://docs.isaacsim.omniverse.nvidia.com/5.0.0/installation/requirements.html) to prepare the appropriate hardware. + +## Prerequisites + +โœ… **Note:** This example is designed to run on Linux with ROS2 installed and has been tested on the following versions: +- **OS**: Ubuntu 20.04, 22.04 +- **ROS2**: Foxy, Humble +- **Isaac Sim**: Local versions 4.5.0, 5.0.0 and Docker versions 4.5.0, 5.0.0 + +This example is written based on **Docker Isaac Sim 5.0.0** to maximize accessibility and minimize dependencies. + +โš ๏ธ **Windows Users:** If you're using Windows with WSL2, Isaac Sim Docker will not work due to WSL2 GPU passthrough limitations. Instead, install Isaac Sim natively on Windows and follow the Windows-specific instructions in the Quick Start section below. + +**Linux Users:** +Before starting this example, make sure you have the following installed: +- **ROS2**: [Install ROS2](https://docs.ros.org/en/dashing/Installation/Ubuntu-Install-Binary.html) +- **Docker**: [Install Docker](https://docs.docker.com/get-docker/) + +**Windows Users:** +Before starting this example, make sure you have the following installed: +- **WSL2**: [Install WSL2](https://learn.microsoft.com/en-us/windows/wsl/install) +- **ROS2 in WSL2**: [Install ROS2](https://docs.ros.org/en/humble/Installation/Ubuntu-Install-Binary.html) +- **NVIDIA GPU Drivers**: Latest drivers for your GPU + +## Quick Start + +### Setup +
+For Linux: Docker-based Setup + +### 1. Isaac Sim Container Installation +Follow the instructions on the [Isaac Sim Container Installation](https://docs.isaacsim.omniverse.nvidia.com/5.0.0/installation/install_container.html) to install the Isaac Sim Docker container. + +### 2. Download Isaac Sim WebRTC Streaming Client +Follow the instructions on the [Isaac Sim WebRTC Streaming Client](https://docs.isaacsim.omniverse.nvidia.com/5.0.0/installation/download.html#isaac-sim-latest-release) to download and install the WebRTC streaming client. +In this example, the WebRTC Streaming Client was downloaded and executed as follows. + + +#### 3. Check Installation +To verify that the Isaac Sim Container and WebRTC Streaming Client are properly installed, please follow the steps below. + +In the terminal where the Isaac Sim Container is running, execute the following command to run Isaac Sim with native livestream mode. +```bash +./runheadless.sh -v +``` +When the message `Isaac Sim Full Streaming App is loaded.` appears, it means the application has been launched successfully. + +After that, move to the folder where you downloaded the Isaac Sim WebRTC Streaming Client in a new terminal and run the following command to execute the client. +```bash +./isaacsim-webrtc-streaming-client-1.1.4-linux-x64.AppImage +``` + +When the GUI is displayed as shown below, it means the application has been launched successfully. + + + +If the above steps have been executed successfully, press the Connect button on the Isaac Sim WebRTC Streaming Client to check if the Isaac Sim screen appears. +โœ… **Note:** This example is based on installing and running the Isaac Sim container and the Isaac Sim WebRTC streaming client on the same local PC. Therefore, the server address for the Isaac Sim WebRTC Streaming Client is `127.0.0.1`. If the Isaac Sim container is running on another PC, you need to enter the IP address of that PC. + + + +### 4. Copy LIMO example USD file to Isaac Sim +Copy the USD file of the LIMO robot created for this example to Isaac Sim. + +```bash +cd /ros-mcp-server/examples/3_limo_mobile_robot/isaac_sim/usd/ +docker exec mkdir -p /example # default container_name : isaac-sim +docker cp ./limo_example.usd :/example/limo_example.usd +``` + +If successfully copied, you can verify the USD file in the Isaac Sim screen as shown below. +```bash +Path : My Computer > / > example > limo_example.usd +``` + + + +### 5. Launch LIMO Example +Now, double-click the `limo_example.usd` file to run it in Isaac Sim. You should see the LIMO robot loading in the simulation environment as shown below. + + + +### 3. Start rosbridge + +If the LIMO robot is running properly in Isaac Sim and ROS2 topics have been created, run rosbridge in the terminal to enable communication with the ros-mcp-server. Execute rosbridge using the following command: + +```bash +ros2 launch rosbridge_server rosbridge_websocket_launch.xml +``` + +
+ +
+For Windows: Native Setup with WSL2 + +### 1. Install Isaac Sim for Windows + +Download and install Isaac Sim for Windows: + +1. Visit the [Isaac Sim Download Page](https://docs.isaacsim.omniverse.nvidia.com/5.0.0/installation/download.html) +2. Download the Windows version +3. Extract the archive to your desired location (e.g., `C:\isaac_sim`) + + + +**Note:** Remember the installation path as you'll need it in the next steps. + +### 2. Configure Windows Environment and Launch Isaac Sim + +Open PowerShell and configure the environment variables to enable ROS2 bridge: + +```powershell +# Set Isaac Sim installation path (modify this to match your installation) +$env:ISAAC_SIM_PATH = "C:\isaac_sim" + +# Set ROS2 distribution (change to 'foxy' if using ROS2 Foxy) +$env:ROS_DISTRO = "humble" + +# Set RMW implementation +$env:RMW_IMPLEMENTATION = "rmw_fastrtps_cpp" + +# Add ROS2 bridge library to PATH +$env:PATH = "$env:ISAAC_SIM_PATH\exts\isaacsim.ros2.bridge\$env:ROS_DISTRO\lib;$env:PATH" + +# Move to the isaac folder +cd $env:ISAAC_SIM_PATH + +# Launch Isaac Sim with the ROS2 bridge extension +& "$env:ISAAC_SIM_PATH\isaac-sim.bat" --/isaac/startup/ros_bridge_extension=isaacsim.ros2.bridge +``` + +**Important:** Replace `C:\isaac_sim` with your actual Isaac Sim installation path, and adjust `ROS_DISTRO` to match your WSL2 ROS2 version. + +Wait for Isaac Sim to fully launch. You should see the Isaac Sim GUI window. + +**Verify ROS2 Bridge:** +- Go to: **Window โ†’ Extensions** +- Search for: `ros2 bridge` +- Confirm that **isaacsim.ros2.bridge** is ON(enabled). +If it is not, make sure you followed the previous instructions. + + + +### 3. Start rosbridge + +If the LIMO robot is running properly in Isaac Sim and ROS2 topics have been created, run rosbridge to enable communication with the **ros-mcp-server**. + +```bash +# Install rosbridge server if not already installed +sudo apt update +sudo apt install ros-humble-rosbridge-server + +# Source again to ensure rosbridge is available +source /opt/ros/humble/setup.bash + +# Set RMW implementation to match Windows +export RMW_IMPLEMENTATION=rmw_fastrtps_cpp + +# Allow ROS2 to communicate across network boundaries +export ROS_LOCALHOST_ONLY=0 + +# Start rosbridge +ros2 launch rosbridge_server rosbridge_websocket_launch.xml +``` + +**Note:** If you're using ROS2 Foxy, replace `humble` with `foxy` in the commands above. + +### 4. Load LIMO Example in Isaac Sim + +1. **Load USD File** + - In Isaac Sim, go to: **File โ†’ Open** + - Navigate to your cloned repository location: + - If cloned in Windows: `\ros-mcp-server\examples\3_limo_mobile_robot\isaac_sim\usd\limo_example.usd` + - If cloned in WSL2: Access via `\\wsl$\\\ros-mcp-server\examples\3_limo_mobile_robot\isaac_sim\usd\limo_example.usd` + - Select and open `limo_example.usd` + + **Alternative:** Drag and drop the USD file from the Content panel at the bottom into the Viewport + +You should see the LIMO robot loading in the simulation environment as shown below. + + + +
+ +#### Start Simulation and Check Topics + +Now, let's check if the LIMO robot is running properly in Isaac Sim. + +1. **Start Simulation** + - Click the **โ–ถ (Play)** button in the toolbar on the left + - The button will change to **|| (Pause)** when the physics engine starts running + + +2. **Check ROS2 Topics** + + Once the simulation starts, ROS2 topics will be created through a predefined [Action Graph](https://docs.isaacsim.omniverse.nvidia.com/latest/omnigraph/omnigraph_tutorial.html) within the USD file. + + Run the following command in your terminal (Linux) or WSL2 terminal (Windows): + + ```bash + ros2 topic list + ``` + + + + As shown in the figure, `/camera/image_raw`, `/cmd_vel`, and `/scan` must appear as essential topics. + + Each topic serves the following functions: + - `/camera/image_raw`: Transmits raw image data collected from the LIMO robot's camera + - `/cmd_vel`: Transmits movement commands for the LIMO robot + - `/scan`: Transmits scan data collected from the LIMO robot's LiDAR sensor + +## **Integration with MCP Server** + +If rosbridge is running, you can connect the MCP server to control the robot. If you havenโ€™t set up the MCP server yet, follow theย [installation guide](https://github.com/robotmcp/ros-mcp-server/blob/main/docs/install/installation.md)ย . + +Since The **ros-mcp-server** needs to recognize the robot, configure it to connect to the robotโ€™s IP address. + +## **Example Walkthrough** +Once all the above connections are completed, you can connect to and control the LIMO robot from the **ros-mcp-server**. Below is an example screen showing the connection to the LIMO robot from the **ros-mcp-server**. + +### **Example 1** : Connect to robot + +**For Linux:** By default, the Isaac Sim Container is run with the `--network=host` option, so it can access the LIMO robot on the same network as the user's local PC. Therefore, the IP address of the LIMO robot is the same as the user's local PC. Use the `ifconfig` command to find your IP address. + +**For Windows:** Find your Windows IP address from WSL2 using: `ip route show | grep -i default | awk '{ print $3}'` + + + +### **Example 2** : ROS2 Topic Check + + + +### **Example 3** : Simple Movement + + + +## **Next Steps** +The LIMO is equipped with various sensors, such as vision and LiDAR sensors. Let's make use of them. \ No newline at end of file diff --git a/examples/3_limo_mobile_robot/isaac_sim/usd/limo_example.usd b/examples/3_limo_mobile_robot/isaac_sim/usd/limo_example.usd new file mode 100644 index 0000000..e6a6bde Binary files /dev/null and b/examples/3_limo_mobile_robot/isaac_sim/usd/limo_example.usd differ diff --git a/examples/3_limo_mobile_robot/real_robot/README.md b/examples/3_limo_mobile_robot/real_robot/README.md new file mode 100644 index 0000000..c0a8553 --- /dev/null +++ b/examples/3_limo_mobile_robot/real_robot/README.md @@ -0,0 +1,177 @@ +# Example - LIMO (Real) +![Static Badge](https://img.shields.io/badge/ROS-Available-green) + +This is an example using the real robot, AgileX Robotics LIMO, in the ROS ecosystem. + +The LIMO is a ROS-based mobile robot platform for education and research, offering features like SLAM and navigation on various hardware configuration. +## Prerequisites + +For this example, the LIMO Standard version with Ubuntu 18.04 and ROS1 Melodic is used. Since each LIMO variant comes with different vision sensors, LiDAR sensors, and ROS versions, make sure to install the corresponding packages required for your specific model. + + +### AgileX LIMO +For more details, please refer to the [LIMO Documentation](https://docs.trossenrobotics.com/agilex_limo_docs/). + + + + +- **Specification** + - **OS** : Ubuntu 18.04 + - **ROS** : ROS1 Melodic + - **IPC** : NVIDIA Jetson Nano (4G) + - **Vision Sensor** : Dabai U3 + - **LiDAR** : EAI X2L + + +
+Before starting this tutorial, make sure you have the following installed. It is recommended to use a single workspace. If they are already installed, you may skip this step. + +
+ +- **Limo SDK (ROS1)** : [LIMO SDK](https://github.com/agilexrobotics/limo_ros) +- **Ydlidar Ros Driver** : [Ydlidar Ros Driver](https://github.com/YDLIDAR/ydlidar_ros_driver) +- **Ros Astra Camera** : [ROS Astra Camera](https://github.com/orbbec/ros_astra_camera) + + +## **Framework** +The AI system communicates with the ROS-MCP via the MCP protocol, which connects through ROSbridge to LIMO's ROS nodes. ROSbridge converts ROS messages to JSON format over WebSocket, while the nodes handle topics to conrol hardware and exchange data. + + + +## Quick Start + +### 1. Network Setup + +Since the LIMO is controlled via an ROS-MCP from the user PC, it is important to connect both the user PC and the LIMO to the same network. +- **Ping Test** + + After connecting them to the same network, perform a ping test from the user PC to LIMO to verify that the connection is established correctly: + + ```bash + ping # e.g., ping 192.168.0.20 + ``` + +- **ROS Network setup** + + In ROS1, nodes must know which host runs the ROS master. + + On the LIMO, set the `ROS_MASTER_URI` environment variable: + + ```bash + echo "export ROS_MASTER_URI=http://:11311" >> ~/.bashrc + echo "export ROS_IP=" >> ~/.bashrc + source ~/.bashrc + ``` +### 2. SSH Setup + +For convenience in file transfer, SSH (Secure Shell) is enabled on the robot before setup. + +- **SSH server installation on the LIMO:** + + ```bash + sudo apt update + sudo apt install openssh-server + ``` + +- **Service activation:** + ```bash + sudo systemctl enable ssh + sudo systemctl start ssh + sudo systemctl status ssh + ``` + If it shows `active (running)`, it means the service is running properly. + +- **Connect to Robot on the user PC:** + + `` is the robot's username (default: agilex), and ``is the robot's network IP address, which you can find using the `ifconfig` command on the LIMO. + ```bash + ssh @ # e.g., ssh agilex@192.168.0.20 +### 3. File Upload +After connecting via SSH, the following commands are executed on the user PC. + + + +- **Set helper node (`cmd_vel` repeat node):** + + + On the LIMO, `cmd_vel` is consumed and applied to the motors immediately. This makes it hard to execute ROS-MCP generated motion commands accurately, since the ROS-MCP may not publish at a steady high-frequency rate. To compensate, a helper node is added that re-publishes the last command at a fixed frequency. + + To ensure the ROS-MCP can use the topic unambiguously, the motor's input is remapped from `cmd_vel` to `cmd_vel_to_motor`, and configure the repeater to subscribe to `cmd_vel` (from the ROS-MCP) and republish it to `cmd_vel_to_motor`. + + ```bash + cd //ros-mcp-server + scp ./examples/3_limo_mobile_robot/real_robot/scripts \ + @:~/catkin_ws/src/limo_base + ssh @ \ + "chmod +x ~/catkin_ws/src/limo_base/scripts/cmd_vel_repeat.py" + ``` + +- **Replace launch file:** + + ```bash + cd //ros-mcp-server + scp /examples/3_limo_mobile_robot/real_robot/launch/limo_base.launch \ + @:~/catkin_ws/src/limo_base/launch/ + scp ./examples/3_limo_mobile_robot/real_robot/launch/limo_start.launch \ + @:~/catkin_ws/src/limo_bringup/launch/ + ``` + +### 4. Launch Node +To use the robot's topics in ROS-MCP, each node is launched on the robot. +- **Start the base driver:** + + ```bash + roslaunch limo_base limo_start.launch + ``` + +- **Start the Camera (DaBai U3):** + + The LIMO standard uses Astraโ€™s DaBai U3 depth camera. + launch the cam node: + + ```bash + roslaunch astra_camera dabai_u3.launch + ``` + +- **Start the `cmd_vel` repeater:** + + ```bash + rosrun limo_base cmd_vel_repeat.py + ``` + +- **Start rosbridge:** + + ```bash + roslaunch rosbridge_server rosbridge_websocket.launch + ``` +## **Integration with MCP Server** + +Once rosbridge is running on the LIMO and your PC is on the same network, you can connect the MCP server to control the robot. If you havenโ€™t set up the MCP server yet, follow theย [installation guide](https://github.com/robotmcp/ros-mcp-server/blob/main/docs/install/installation.md)ย . + +Since The ROS-MCP to recognize the robot, configure it to connect to the robotโ€™s IP address. + +## **Example Walkthrough** +After connecting to the robot, you can inspect which topics the robot subscribes to and what it can do. + +### **Example 1** : Connect to robot + + + +### **Example 2** : What can you do? + + + + + +### **Example 3** : Simple Movement + + +## **Next Steps** + +1. **Try running the navigation demo** + + With the SLAM and navigation fratures provided by LIMO, more complex tasks can be carride out. + +2. **Try more complex commands** + + The LIMO is equipped with various sensors, such as vision and LiDAR sensors. Let's make use of them. \ No newline at end of file diff --git a/examples/3_limo_mobile_robot/real_robot/launch/limo_base.launch b/examples/3_limo_mobile_robot/real_robot/launch/limo_base.launch new file mode 100644 index 0000000..9f3e7e6 --- /dev/null +++ b/examples/3_limo_mobile_robot/real_robot/launch/limo_base.launch @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/examples/3_limo_mobile_robot/real_robot/launch/limo_start.launch b/examples/3_limo_mobile_robot/real_robot/launch/limo_start.launch new file mode 100644 index 0000000..ceea16c --- /dev/null +++ b/examples/3_limo_mobile_robot/real_robot/launch/limo_start.launch @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/3_limo_mobile_robot/real_robot/scripts/cmd_vel_repeat.py b/examples/3_limo_mobile_robot/real_robot/scripts/cmd_vel_repeat.py new file mode 100644 index 0000000..fe7bcb1 --- /dev/null +++ b/examples/3_limo_mobile_robot/real_robot/scripts/cmd_vel_repeat.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python +import rospy +from geometry_msgs.msg import Twist + + +class CmdVelLatchRepeater(object): + def __init__(self): + self.in_topic = rospy.get_param("~in_topic", "/cmd_vel") + self.out_topic = rospy.get_param("~out_topic", "/cmd_vel_to_motor") + self.rate_hz = rospy.get_param("~rate_hz", 10.0) + self.stop_on_shutdown = rospy.get_param("~stop_on_shutdown", True) + + self.pub = rospy.Publisher(self.out_topic, Twist, queue_size=5) + self.sub = rospy.Subscriber(self.in_topic, Twist, self.cb, queue_size=5) + + self.last_cmd = None + rospy.loginfo( + "latch repeater: %s -> %s @ %.1fHz", self.in_topic, self.out_topic, self.rate_hz + ) + + def cb(self, msg): + self.last_cmd = msg + + def spin(self): + r = rospy.Rate(self.rate_hz) + while not rospy.is_shutdown(): + if self.last_cmd is not None: + self.pub.publish(self.last_cmd) + r.sleep() + + def shutdown(self): + if self.stop_on_shutdown: + self.pub.publish(Twist()) + + +if __name__ == "__main__": + rospy.init_node("cmd_vel_latch_repeater") + node = CmdVelLatchRepeater() + rospy.on_shutdown(node.shutdown) + node.spin() diff --git a/examples/4_unitree_go2/images/unitree_go2_isaac_sim_connect.png b/examples/4_unitree_go2/images/unitree_go2_isaac_sim_connect.png new file mode 100644 index 0000000..b41e133 Binary files /dev/null and b/examples/4_unitree_go2/images/unitree_go2_isaac_sim_connect.png differ diff --git a/examples/4_unitree_go2/images/unitree_go2_isaac_sim_custom_env.png b/examples/4_unitree_go2/images/unitree_go2_isaac_sim_custom_env.png new file mode 100644 index 0000000..83eaae1 Binary files /dev/null and b/examples/4_unitree_go2/images/unitree_go2_isaac_sim_custom_env.png differ diff --git a/examples/4_unitree_go2/images/unitree_go2_isaac_sim_gettopics.png b/examples/4_unitree_go2/images/unitree_go2_isaac_sim_gettopics.png new file mode 100644 index 0000000..f1e68ce Binary files /dev/null and b/examples/4_unitree_go2/images/unitree_go2_isaac_sim_gettopics.png differ diff --git a/examples/4_unitree_go2/images/unitree_go2_isaac_sim_movearound.png b/examples/4_unitree_go2/images/unitree_go2_isaac_sim_movearound.png new file mode 100644 index 0000000..e292507 Binary files /dev/null and b/examples/4_unitree_go2/images/unitree_go2_isaac_sim_movearound.png differ diff --git a/examples/4_unitree_go2/images/unitree_go2_isaac_sim_topiclist.png b/examples/4_unitree_go2/images/unitree_go2_isaac_sim_topiclist.png new file mode 100644 index 0000000..5abe9bd Binary files /dev/null and b/examples/4_unitree_go2/images/unitree_go2_isaac_sim_topiclist.png differ diff --git a/examples/4_unitree_go2/images/unitree_go2_real.png b/examples/4_unitree_go2/images/unitree_go2_real.png new file mode 100644 index 0000000..207c412 Binary files /dev/null and b/examples/4_unitree_go2/images/unitree_go2_real.png differ diff --git a/examples/4_unitree_go2/images/unitree_go2_real_camera.png b/examples/4_unitree_go2/images/unitree_go2_real_camera.png new file mode 100644 index 0000000..945bc03 Binary files /dev/null and b/examples/4_unitree_go2/images/unitree_go2_real_camera.png differ diff --git a/examples/4_unitree_go2/images/unitree_go2_real_connect.png b/examples/4_unitree_go2/images/unitree_go2_real_connect.png new file mode 100644 index 0000000..6df24c7 Binary files /dev/null and b/examples/4_unitree_go2/images/unitree_go2_real_connect.png differ diff --git a/examples/4_unitree_go2/images/unitree_go2_real_router_connect.png b/examples/4_unitree_go2/images/unitree_go2_real_router_connect.png new file mode 100644 index 0000000..99a0a61 Binary files /dev/null and b/examples/4_unitree_go2/images/unitree_go2_real_router_connect.png differ diff --git a/examples/4_unitree_go2/images/unitree_go2_real_router_dhcp.png b/examples/4_unitree_go2/images/unitree_go2_real_router_dhcp.png new file mode 100644 index 0000000..3c91e2b Binary files /dev/null and b/examples/4_unitree_go2/images/unitree_go2_real_router_dhcp.png differ diff --git a/examples/4_unitree_go2/images/unitree_go2_real_router_ip.png b/examples/4_unitree_go2/images/unitree_go2_real_router_ip.png new file mode 100644 index 0000000..73fa79b Binary files /dev/null and b/examples/4_unitree_go2/images/unitree_go2_real_router_ip.png differ diff --git a/examples/4_unitree_go2/images/unitree_go2_real_router_ip_change.png b/examples/4_unitree_go2/images/unitree_go2_real_router_ip_change.png new file mode 100644 index 0000000..e3aa458 Binary files /dev/null and b/examples/4_unitree_go2/images/unitree_go2_real_router_ip_change.png differ diff --git a/examples/4_unitree_go2/images/unitree_go2_real_ssh.png b/examples/4_unitree_go2/images/unitree_go2_real_ssh.png new file mode 100644 index 0000000..79bec68 Binary files /dev/null and b/examples/4_unitree_go2/images/unitree_go2_real_ssh.png differ diff --git a/examples/4_unitree_go2/images/unitree_go2_real_topics.png b/examples/4_unitree_go2/images/unitree_go2_real_topics.png new file mode 100644 index 0000000..5efcb58 Binary files /dev/null and b/examples/4_unitree_go2/images/unitree_go2_real_topics.png differ diff --git a/examples/4_unitree_go2/isaac_sim/README.md b/examples/4_unitree_go2/isaac_sim/README.md new file mode 100644 index 0000000..5a73df6 --- /dev/null +++ b/examples/4_unitree_go2/isaac_sim/README.md @@ -0,0 +1,146 @@ +# Example - Unitree GO2 (Isaac Sim) +![Static Badge](https://img.shields.io/badge/ROS2-Available-green) +[![IsaacSim](https://img.shields.io/badge/IsaacSim-4.5.0-silver.svg)](https://docs.omniverse.nvidia.com/isaacsim/latest/overview.html) +[![Python](https://img.shields.io/badge/python-3.10-blue.svg)](https://docs.python.org/3/whatsnew/3.10.html) + +This example is built on the [go2_omniverse](https://github.com/abizovnuralem/go2_omniverse/tree/added_copter?tab=readme-ov-file) repository by @abizovnuralem. The repo enables seamless integration with ROS 2, giving access to various sensors such as LiDAR, IMU, and cameras, and allows you to easily leverage the locomotion model trained in Isaac Lab. +Using this setup, an environment is built to test and demonstrate the capabilities of ROS-MCP in the Isaac Sim. + +## System Requirements + This example requires a PC that meets the minimum specifications to run Isaac Sim. Please refer to the [System Requirements](https://docs.isaacsim.omniverse.nvidia.com/4.5.0/installation/requirements.html) to prepare the appropriate hardware. + + + +## Prerequisites +โœ… **Note:** This example is designed to run on Linux with ROS2 installed and has been tested on the following versions: +- **OS**: Ubuntu 22.04 +- **ROS2**: Humble +- **Isaac Sim**: 4.5.0 +- **Isaac Lab**: 2.1.1 (recommmended) + + +Before starting this example, make sure you have the following installed: +- **ROS2** : [Install ROS2](https://docs.ros.org/en/dashing/Installation/Ubuntu-Install-Binary.html) + + +## Quick Start +### 1. IsaacSim & IsaacLab install in conda environment + +Follow the instructions on the [Isaac Sim & Isaac Lab installation](https://isaac-sim.github.io/IsaacLab/v2.1.1/source/setup/installation/pip_installation.html) to install the Isaac Sim and Isaac Lab. + +To avoid potential conflicts when working locally, it is recommended to work within a **conda** environment. + +### 2. Environment Setup +Isaac Sim simulation environment for running the robot. + +โœ… **Note:** In this step, the process is based on the [go2_omniverse](https://github.com/abizovnuralem/go2_omniverse/tree/added_copter?tab=readme-ov-file) repository. See its README for more details. + +- **Downloading the code** + + This example uses the **added_copter** branch, which is built on Isaac Sim 4.5.0. + + ```bash + git clone -b added_copter https://github.com/abizovnuralem/go2_omniverse/ --recurse-submodules -j8 --depth=1 + ``` +- **Setup the RTX Lidar** + + 1. First check the directory of the virtual environment where Isaac Sim was installed: + + ```bash + conda info --envs + ``` + 2. Update the Isaac Sim extension definition file to include the Unitree L1 lidar: + + ```bash + cp //ros-mcp-server/examples/4_unitree_go2/isaac_sim/scripts/extension.toml \ + /lib/python3.10/site-packages/isaacsim/exts/isaacsim.sensors.rtx/config/ + ``` + 3. Add the Unitree L1 lidar config file to Isaac Lab repo folder: + + ```bash + mkdir -p /lib/python3.10/site-packages/isaacsim/exts/isaacsim.sensors.rtx/data/lidar_configs/Unitree + + cp //go2_omniverse/Isaac_sim/Unitree/Unitree_L1.json \ + /lib/python3.10/site-packages/isaacsim/exts/isaacsim.sensors.rtx/data/lidar_configs/Unitree/ + ``` +- **Custom Environment setup** + + To effectively showcase the capabilities of ROS-MCP, the small warehouse environment proivided by Isaac Sim was used, and the setup was configured to allow users to start using ROS-MCP right away. + + + + To set up the environment, several files in go2_omniverse need to be replaced with the files provided in this repository. + + ```bash + cd //ros-mcp-server/examples/4_unitree_go2/isaac_sim/scripts + cp custom_rl_env.py omniverse_sim.py //go2_omniverse/ + ``` + + Finally, in the `run_sim.sh` file of go2_omniverse, add `--custom_env small_warehouse` to the Python execution command. + +### 3. Launch + +To use the robot's topics in ROS-MCP, each node is launched. + +1. First, launch the simulation node. + + + ```bash + #Launch simulation node + cd //go2_omniverse/ + ./run_sim.sh + ``` +2. After that, open a new terminal and start rosbridge. + + ```bash + # Launch rosbridge + ros2 launch rosbridge_server rosbridge_websocket_launch.xml + ``` +3. You can verify the available topics for the Go2 robot by running the ros2 topic list command. + + ```bash + ros2 topic list + ``` + + + The key topics serve the following functions: + + - `/robot0/cmd_vel` : Transmits movement commands for the robot. + - `/robot0/front_cam/rgb` : Transmits raw image data collected from the robot's camera. + - `/robot0/point_cloud2_L1` : Publishes point cloud data from the Unitree L1 LiDAR. + - `/robot0/point_cloud2_extra` : Publishes point cloud data from the extra simulated LiDAR. (If the extra sensor is not needed, it is recommended to disable its publishing.) + +## **Integration with MCP Server** + +If rosbridge is running, you can connect the MCP server to control the robot. If you havenโ€™t set up the MCP server yet, follow theย [installation guide](https://github.com/robotmcp/ros-mcp-server/blob/main/docs/install/installation.md)ย . + +## **Example Walkthrough** +Once all the above connections are completed, you can connect to and control the Go2 robot from the **ros-mcp-server**. Below is an example screen showing the connection to the Go2 robot from the **ros-mcp-server**. + +### **Example 1** : Connect to robot + + + +### **Example 2** : Check available topics + + + + +### **Example 3** : Move around and observe ([video](https://www.youtube.com/watch?v=9StFx4lnvmc)) + + + + +## **Troubleshooting** +- ### **CUDA Driver failures(RTX lidar issue)** + ```bash + [Error] [omni.sensors.nv.lidar.lidar_core.plugin] CUDA Driver CALL FAILED at line 522: the provided PTX was compiled with an unsupported toolchain. + [Error] [omni.sensors.nv.lidar.lidar_core.plugin] CUDA Driver CALL FAILED at line 548: named symbol not found + ``` + + 1. First, refer to the [Isaac Sim known issues](https://docs.isaacsim.omniverse.nvidia.com/4.5.0/overview/known_issues.html#:~:text=CUDA%20driver%20failures%20from%20the%20omni.sensors.nv.lidar.lidar_core.plugin%20(example%20below)%20on%20Ubuntu%20may%20be%20due%20to%20a%20system%2Dlevel%20CUDA%20installation%20mismatch%20with%20the%20omni.sensors%20runtime%2Dcompiled%20libraries.) and try applying those fixes first. + + 2. If the issue still persists, consider upgrading the nvidia driver version to 570 or later. + +## **Next Steps** +The Unitree Go2 has various sensors and functionalities, so let's make use of them by adding ROS nodes such as Nav2. \ No newline at end of file diff --git a/examples/4_unitree_go2/isaac_sim/scripts/custom_rl_env.py b/examples/4_unitree_go2/isaac_sim/scripts/custom_rl_env.py new file mode 100644 index 0000000..02787ea --- /dev/null +++ b/examples/4_unitree_go2/isaac_sim/scripts/custom_rl_env.py @@ -0,0 +1,348 @@ +# Copyright (c) 2024, RoboVerse community +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +import math +from dataclasses import MISSING +from math import sqrt +from typing import Literal + +import isaaclab.sim as sim_utils +import isaaclab_tasks.manager_based.locomotion.velocity.mdp as mdp +import torch +from isaaclab.assets import ArticulationCfg, AssetBaseCfg +from isaaclab.envs import ManagerBasedRLEnvCfg +from isaaclab.managers import EventTermCfg as EventTerm +from isaaclab.managers import ObservationGroupCfg as ObsGroup +from isaaclab.managers import ObservationTermCfg as ObsTerm +from isaaclab.managers import RewardTermCfg as RewTerm +from isaaclab.managers import SceneEntityCfg +from isaaclab.managers import TerminationTermCfg as DoneTerm +from isaaclab.scene import InteractiveSceneCfg +from isaaclab.sensors import ContactSensorCfg, RayCasterCfg, patterns +from isaaclab.terrains import TerrainImporterCfg +from isaaclab.utils import configclass +from isaaclab.utils.noise import AdditiveUniformNoiseCfg as Unoise +from isaaclab_assets.robots.unitree import UNITREE_GO2_CFG +from robots.g1.config import G1_CFG + +base_command = {} + + +def constant_commands(env: ManagerBasedRLEnvCfg) -> torch.Tensor: + global base_command + """The generated command from the command generator.""" + # tensor_lst = torch.tensor([0, 0, 0], device=env.device).repeat(env.num_envs, 1) + tensor_lst = torch.tensor([0.0, 0.0, 0.0], dtype=torch.float32, device=env.device).repeat( + env.num_envs, 1 + ) + for i in range(env.num_envs): + tensor_lst[i] = torch.tensor(base_command[str(i)], device=env.device) + return tensor_lst + + +@configclass +class MySceneCfg(InteractiveSceneCfg): + """Configuration for the terrain scene with a legged robot.""" + + # ground terrain + terrain = TerrainImporterCfg( + prim_path="/World/ground", + terrain_type="plane", + debug_vis=False, + ) + + # robots + robot: ArticulationCfg = MISSING + + height_scanner = RayCasterCfg( + prim_path="{ENV_REGEX_NS}/Robot/base", + offset=RayCasterCfg.OffsetCfg(pos=(0.0, 0.0, 20.0)), + # attach_yaw_only=True, + ray_alignment="yaw", + pattern_cfg=patterns.GridPatternCfg(resolution=0.1, size=[1.6, 1.0]), + debug_vis=False, + mesh_prim_paths=["/World/ground"], + ) + + contact_forces = ContactSensorCfg( + prim_path="{ENV_REGEX_NS}/Robot/.*", history_length=3, track_air_time=True + ) + + # lights + light = AssetBaseCfg( + prim_path="/World/light", + spawn=sim_utils.DistantLightCfg(color=(0.75, 0.75, 0.75), intensity=3000.0), + ) + + sky_light = AssetBaseCfg( + prim_path="/World/skyLight", + spawn=sim_utils.DomeLightCfg(color=(0.13, 0.13, 0.13), intensity=1000.0), + ) + + +@configclass +class ViewerCfg: + """Configuration of the scene viewport camera.""" + + eye: tuple[float, float, float] = (-2.5, -7.8, 4.0) + + lookat: tuple[float, float, float] = (3.0, 2.0, -2.5) + + cam_prim_path: str = "/OmniverseKit_Persp" + + resolution: tuple[int, int] = (1920, 1080) + + origin_type: Literal["world", "env", "asset_root"] = "world" + + env_index: int = 0 + + asset_name: str | None = None + + +@configclass +class ObservationsCfg: + """Observation specifications for the MDP.""" + + @configclass + class PolicyCfg(ObsGroup): + """Observations for policy group.""" + + # observation terms (order preserved) + base_lin_vel = ObsTerm(func=mdp.base_lin_vel) + base_ang_vel = ObsTerm(func=mdp.base_ang_vel) + projected_gravity = ObsTerm( + func=mdp.projected_gravity, + noise=Unoise(n_min=-0.05, n_max=0.05), + ) + velocity_commands = ObsTerm(func=constant_commands) + joint_pos = ObsTerm(func=mdp.joint_pos_rel) + joint_vel = ObsTerm(func=mdp.joint_vel_rel) + actions = ObsTerm(func=mdp.last_action) + height_scan = ObsTerm( + func=mdp.height_scan, + params={"sensor_cfg": SceneEntityCfg("height_scanner")}, + clip=(-1.0, 1.0), + ) + + def __post_init__(self): + self.enable_corruption = True + self.concatenate_terms = True + + # observation groups + policy: PolicyCfg = PolicyCfg() + + +@configclass +class ActionsCfg: + """Action specifications for the MDP.""" + + joint_pos = mdp.JointPositionActionCfg( + asset_name="robot", joint_names=[".*"], scale=0.5, use_default_offset=True + ) + + +@configclass +class CommandsCfg: + """Command specifications for the MDP.""" + + base_velocity = mdp.UniformVelocityCommandCfg( + asset_name="robot", + resampling_time_range=(0.0, 0.0), + rel_standing_envs=0.02, + rel_heading_envs=1.0, + heading_command=True, + heading_control_stiffness=0.5, + debug_vis=True, + ranges=mdp.UniformVelocityCommandCfg.Ranges( + lin_vel_x=(0.0, 0.0), + lin_vel_y=(0.0, 0.0), + ang_vel_z=(0.0, 0.0), + heading=(0, 0), + ), + ) + + +@configclass +class RewardsCfg: + """Reward terms for the MDP.""" + + # -- task + track_lin_vel_xy_exp = RewTerm( + func=mdp.track_lin_vel_xy_exp, + weight=1.0, + params={"command_name": "base_velocity", "std": math.sqrt(0.25)}, + ) + track_ang_vel_z_exp = RewTerm( + func=mdp.track_ang_vel_z_exp, + weight=0.5, + params={"command_name": "base_velocity", "std": math.sqrt(0.25)}, + ) + # -- penalties + lin_vel_z_l2 = RewTerm(func=mdp.lin_vel_z_l2, weight=-2.0) + ang_vel_xy_l2 = RewTerm(func=mdp.ang_vel_xy_l2, weight=-0.05) + dof_torques_l2 = RewTerm(func=mdp.joint_torques_l2, weight=-1.0e-5) + dof_acc_l2 = RewTerm(func=mdp.joint_acc_l2, weight=-2.5e-7) + action_rate_l2 = RewTerm(func=mdp.action_rate_l2, weight=-0.01) + feet_air_time = RewTerm( + func=mdp.feet_air_time, + weight=0.125, + params={ + "sensor_cfg": SceneEntityCfg("contact_forces", body_names=".*FOOT"), + "command_name": "base_velocity", + "threshold": 0.5, + }, + ) + undesired_contacts = RewTerm( + func=mdp.undesired_contacts, + weight=-1.0, + params={ + "sensor_cfg": SceneEntityCfg("contact_forces", body_names=".*THIGH"), + "threshold": 1.0, + }, + ) + # -- optional penalties + flat_orientation_l2 = RewTerm(func=mdp.flat_orientation_l2, weight=0.0) + dof_pos_limits = RewTerm(func=mdp.joint_pos_limits, weight=0.0) + + +@configclass +class TerminationsCfg: + """Termination terms for the MDP.""" + + time_out = DoneTerm(func=mdp.time_out, time_out=True) + base_contact = DoneTerm( + func=mdp.illegal_contact, + params={ + "sensor_cfg": SceneEntityCfg("contact_forces", body_names="base"), + "threshold": 1.0, + }, + ) + + +@configclass +class EventCfg: + """Configuration for events.""" + + # startup + physics_material = EventTerm( + func=mdp.randomize_rigid_body_material, + mode="startup", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=".*"), + "static_friction_range": (0.8, 0.8), + "dynamic_friction_range": (0.6, 0.6), + "restitution_range": (0.0, 0.0), + "num_buckets": 64, + }, + ) + + +@configclass +class LocomotionVelocityRoughEnvCfg(ManagerBasedRLEnvCfg): + """Configuration for the locomotion velocity-tracking environment.""" + + # Scene settings + scene: MySceneCfg = MySceneCfg(num_envs=4096, env_spacing=2.5) + viewer: ViewerCfg = ViewerCfg() + # Basic settings + observations: ObservationsCfg = ObservationsCfg() + actions: ActionsCfg = ActionsCfg() + commands: CommandsCfg = CommandsCfg() + # MDP settings + rewards: RewardsCfg = RewardsCfg() + terminations: TerminationsCfg = TerminationsCfg() + events: EventCfg = EventCfg() + + def __post_init__(self): + """Post initialization.""" + # general settings + self.decimation = 4 + self.sim.render_interval = self.decimation + self.episode_length_s = 20.0 + # simulation settings + self.sim.dt = 0.005 + self.sim.disable_contact_processing = True + self.sim.physics_material = self.scene.terrain.physics_material + + # update sensor update periods + # we tick all the sensors based on the smallest update period (physics update period) + if self.scene.height_scanner is not None: + self.scene.height_scanner.update_period = self.decimation * self.sim.dt + + if self.scene.contact_forces is not None: + self.scene.contact_forces.update_period = self.sim.dt + + # check if terrain levels curriculum is enabled - if so, enable curriculum for terrain generator + # this generates terrains with increasing difficulty and is useful for training + if getattr(self.curriculum, "terrain_levels", None) is not None: + if self.scene.terrain.terrain_generator is not None: + self.scene.terrain.terrain_generator.curriculum = True + else: + if self.scene.terrain.terrain_generator is not None: + self.scene.terrain.terrain_generator.curriculum = False + + +@configclass +class UnitreeGo2CustomEnvCfg(LocomotionVelocityRoughEnvCfg): + def __post_init__(self): + # post init of parent + super().__post_init__() + + self.scene.robot = UNITREE_GO2_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") + self.scene.height_scanner.prim_path = "{ENV_REGEX_NS}/Robot/base" + self.scene.robot.init_state.rot = (sqrt(0.5), 0.0, 0.0, -sqrt(0.5)) + + # reduce action scale + self.actions.joint_pos.scale = 0.25 + + # rewards + self.rewards.feet_air_time.params["sensor_cfg"].body_names = ".*_foot" + self.rewards.feet_air_time.weight = 0.01 + self.rewards.undesired_contacts = None + self.rewards.dof_torques_l2.weight = -0.0002 + self.rewards.track_lin_vel_xy_exp.weight = 1.5 + self.rewards.track_ang_vel_z_exp.weight = 0.75 + self.rewards.dof_acc_l2.weight = -2.5e-7 + + # terminations + self.terminations.base_contact.params["sensor_cfg"].body_names = "base" + + +@configclass +class G1RoughEnvCfg(LocomotionVelocityRoughEnvCfg): + def __post_init__(self): + # post init of parent + super().__post_init__() + # Scene + G1_MINIMAL_CFG = G1_CFG.copy() + G1_MINIMAL_CFG.spawn.usd_path = "./robots/g1/g1.usd" + self.scene.robot = G1_MINIMAL_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") + self.scene.height_scanner.prim_path = "{ENV_REGEX_NS}/Robot/torso_link" + + # rewards + self.rewards.feet_air_time.params["sensor_cfg"].body_names = ".*_ankle_roll_link" + self.rewards.undesired_contacts = None + + # Terminations + self.terminations.base_contact.params["sensor_cfg"].body_names = ["torso_link"] diff --git a/examples/4_unitree_go2/isaac_sim/scripts/extension.toml b/examples/4_unitree_go2/isaac_sim/scripts/extension.toml new file mode 100644 index 0000000..138c215 --- /dev/null +++ b/examples/4_unitree_go2/isaac_sim/scripts/extension.toml @@ -0,0 +1,133 @@ +[core] +reloadable = true +order = 0 + +[package] +version = "13.6.3" +category = "Simulation" +title = "Isaac Sim Isaac Sensor Simulation" +description = "Provides APIs for RTX-based sensors, including RTX Lidar & RTX Radar." +authors = ["NVIDIA"] +repository = "" +keywords = ["isaac", "physics", "robotics"] +changelog = "docs/CHANGELOG.md" +readme = "docs/README.md" +preview_image = "data/preview.png" +icon = "data/icon.png" +writeTarget.kit = true + +[dependencies] +"isaacsim.core.api" = {} +"isaacsim.core.deprecation_manager" = {} +"isaacsim.core.nodes" = {} +"isaacsim.gui.components" = {} +"isaacsim.util.debug_draw" = {} +"omni.graph.action" = {} +"omni.graph" = {} +"isaacsim.storage.native" = {} +"omni.kit.numpy.common" = {} +"omni.kit.pip_archive" = {} # pulls in numpy +"omni.replicator.core" = {} +"omni.sensors.nv.common" = {} +"omni.sensors.nv.ids" = {} +"omni.sensors.nv.lidar" = {} +"omni.sensors.nv.radar" = {} +"omni.sensors.tiled" = {} +"omni.syntheticdata" = {} +"omni.timeline" = {} # Needed for simulation to occur +"isaacsim.robot.schema" = {} +"omni.usd" = {} # needed for call to add aov +"usdrt.scenegraph" = {} + +[[python.module]] +name = "isaacsim.sensors.rtx" + +[[python.module]] +name = "isaacsim.sensors.rtx.tests" +public = false + +[[native.plugin]] +path = "bin/*.plugin" +recursive = false + +[settings] +app.sensors.nv.lidar.profileBaseFolder=[ + "${omni.sensors.nv.common}/data/lidar/", + "${isaacsim.sensors.rtx}/data/lidar_configs/HESAI/", + "${isaacsim.sensors.rtx}/data/lidar_configs/NVIDIA/", + "${isaacsim.sensors.rtx}/data/lidar_configs/Ouster/OS0/", + "${isaacsim.sensors.rtx}/data/lidar_configs/Ouster/OS1/", + "${isaacsim.sensors.rtx}/data/lidar_configs/Ouster/OS2/", + "${isaacsim.sensors.rtx}/data/lidar_configs/SICK/", + "${isaacsim.sensors.rtx}/data/lidar_configs/SLAMTEC/", + "${isaacsim.sensors.rtx}/data/lidar_configs/Velodyne/", + "${isaacsim.sensors.rtx}/data/lidar_configs/ZVISION/", + "${isaacsim.sensors.rtx}/data/lidar_configs/Unitree/", + "${app}/../exts/omni.isaac.sensor/data/lidar_configs/Unitree/", + "${isaacsim.sensors.rtx}/data/lidar_configs/"] + +# app.sensors.nv.lidar.outputBufferOnGPU = true +app.sensors.nv.lidar.auxDataOutput = "EXTRA" +app.sensors.nv.lidar.enableVelocity = true +app.sensors.nv.radar.auxDataOutput = "EXTRA" +app.sensors.nv.radar.runWithoutMBVH = false +rtx.rtxsensor.coordinateFrameQuaternion = "0.0,0.0,0.0,1.0" +# sensor material mapping is hard coded for now, and this is needed to enable sensor materials. +rtx.materialDb.rtSensorNameToIdMap="DefaultMaterial:0;AsphaltStandardMaterial:1;AsphaltWeatheredMaterial:2;VegetationGrassMaterial:3;WaterStandardMaterial:4;GlassStandardMaterial:5;FiberGlassMaterial:6;MetalAlloyMaterial:7;MetalAluminumMaterial:8;MetalAluminumOxidizedMaterial:9;PlasticStandardMaterial:10;RetroMarkingsMaterial:11;RetroSignMaterial:12;RubberStandardMaterial:13;SoilClayMaterial:14;ConcreteRoughMaterial:15;ConcreteSmoothMaterial:16;OakTreeBarkMaterial:17;FabricStandardMaterial:18;PlexiGlassStandardMaterial:19;MetalSilverMaterial:20" +renderer.raytracingMotion.enabled=true + +[[test]] +timeout = 900 +dependencies = [ + "omni.hydra.rtx", +] +stdoutFailPatterns.exclude = [ + # This is excluded in at least 3 kit tests. + "*Missing call to destroyResourceBindingSignature()*", + '*Error processing node attribute `outputs:pointInstanceDataType`: dataType attribute is empty*' +] + +args = [ + '--/persistent/isaac/asset_root/default = "https://omniverse-content-production.s3-us-west-2.amazonaws.com/Assets/Isaac/4.5"', + "--/app/asyncRendering=0", + "--/app/asyncRenderingLowLatency=0", + "--/app/fastShutdown=1", + "--/app/file/ignoreUnsavedOnExit=1", + "--/app/hydraEngine/waitIdle=0", + "--/app/renderer/skipWhileMinimized=0", + "--/app/renderer/sleepMsOnFocus=0", + "--/app/renderer/sleepMsOutOfFocus=0", + "--/app/settings/fabricDefaultStageFrameHistoryCount=3", + "--/app/settings/persistent=0", + "--/app/viewport/createCameraModelRep=0", + "--/crashreporter/skipOldDumpUpload=1", + "--/omni/kit/plugin/syncUsdLoads=1", + "--/omni/replicator/asyncRendering=0", + '--/persistent/app/stage/upAxis="Z"', + "--/persistent/app/viewport/defaults/tickRate=120", + "--/persistent/app/viewport/displayOptions=31951", + "--/persistent/omni/replicator/captureOnPlay=1", + "--/persistent/omnigraph/updateToUsd=0", + "--/persistent/physics/visualizationDisplayJoints=0", + "--/persistent/renderer/startupMessageDisplayed=1", + "--/persistent/simulation/defaultMetersPerUnit=1.0", + "--/persistent/simulation/minFrameRate=15", + "--/renderer/multiGpu/autoEnable=0", + "--/renderer/multiGpu/enabled=0", + "--/rtx-transient/dlssg/enabled=0", + "--/'rtx-transient'/resourcemanager/enableTextureStreaming=1", + "--/rtx/descriptorSets=360000", + "--/rtx/hydra/enableSemanticSchema=1", + "--/rtx/hydra/materialSyncLoads=1", + "--/rtx/materialDb/syncLoads=1", + "--/rtx/newDenoiser/enabled=1", + "--/rtx/reservedDescriptors=900000", + '--/exts/omni.usd/locking/onClose=0', + "--vulkan", +] + +[[test]] +name = "startup" +args = [ + '--/app/settings/fabricDefaultStageFrameHistoryCount = 3', +] diff --git a/examples/4_unitree_go2/isaac_sim/scripts/omniverse_sim.py b/examples/4_unitree_go2/isaac_sim/scripts/omniverse_sim.py new file mode 100644 index 0000000..dbe6b52 --- /dev/null +++ b/examples/4_unitree_go2/isaac_sim/scripts/omniverse_sim.py @@ -0,0 +1,309 @@ +# Copyright (c) 2024, RoboVerse community +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +from __future__ import annotations + +import argparse +import os +import threading + +import carb +import cli_args +import custom_rl_env +import gymnasium as gym +import isaaclab.sim as sim_utils +import omni +import omni.appwindow +import rclpy +import torch +from agent_cfg import unitree_g1_agent_cfg, unitree_go2_agent_cfg +from custom_rl_env import G1RoughEnvCfg, UnitreeGo2CustomEnvCfg +from geometry_msgs.msg import Twist +from isaaclab.app import AppLauncher +from isaaclab_rl.rsl_rl import ( + RslRlOnPolicyRunnerCfg, + RslRlVecEnvWrapper, +) +from isaaclab_tasks.utils import get_checkpoint_path +from isaacsim.storage.native import get_assets_root_path +from omnigraph import create_front_cam_omnigraph +from ros2 import RobotBaseNode, add_camera, add_rtx_lidar, pub_robo_data_ros2 +from rsl_rl.runners import OnPolicyRunner + +# add argparse arguments +parser = argparse.ArgumentParser(description="Train an RL agent with RSL-RL.") +# parser.add_argument("--device", type=str, default="cpu", help="Use CPU pipeline.") +parser.add_argument( + "--disable_fabric", + action="store_true", + default=False, + help="Disable fabric and use USD I/O operations.", +) +parser.add_argument("--num_envs", type=int, default=1, help="Number of environments to simulate.") +parser.add_argument( + "--task", + type=str, + default="Isaac-Velocity-Rough-Unitree-Go2-v0", + help="Name of the task.", +) +parser.add_argument("--seed", type=int, default=None, help="Seed used for the environment") +parser.add_argument("--custom_env", type=str, default="", help="Setup the environment") +parser.add_argument("--robot", type=str, default="go2", help="Setup the robot") +parser.add_argument("--robot_amount", type=int, default=1, help="Setup the robot amount") + + +# append RSL-RL cli arguments +cli_args.add_rsl_rl_args(parser) + + +# append AppLauncher cli args +AppLauncher.add_app_launcher_args(parser) +args_cli = parser.parse_args() + + +# launch omniverse app +app_launcher = AppLauncher(args_cli) +simulation_app = app_launcher.app + + +ext_manager = omni.kit.app.get_app().get_extension_manager() +ext_manager.set_extension_enabled_immediate("isaacsim.ros2.bridge", True) + +# FOR VR SUPPORT +# ext_manager.set_extension_enabled_immediate("omni.kit.xr.core", True) +# ext_manager.set_extension_enabled_immediate("omni.kit.xr.system.steamvr", True) +# ext_manager.set_extension_enabled_immediate("omni.kit.xr.system.simulatedxr", True) +# ext_manager.set_extension_enabled_immediate("omni.kit.xr.system.openxr", True) +# ext_manager.set_extension_enabled_immediate("omni.kit.xr.telemetry", True) +# ext_manager.set_extension_enabled_immediate("omni.kit.xr.profile.vr", True) + + +def sub_keyboard_event(event, *args, **kwargs) -> bool: + if len(custom_rl_env.base_command) > 0: + if event.type == carb.input.KeyboardEventType.KEY_PRESS: + if event.input.name == "W": + custom_rl_env.base_command["0"] = [1, 0, 0] + if event.input.name == "S": + custom_rl_env.base_command["0"] = [-1, 0, 0] + if event.input.name == "A": + custom_rl_env.base_command["0"] = [0, 1, 0] + if event.input.name == "D": + custom_rl_env.base_command["0"] = [0, -1, 0] + if event.input.name == "Q": + custom_rl_env.base_command["0"] = [0, 0, 1] + if event.input.name == "E": + custom_rl_env.base_command["0"] = [0, 0, -1] + + if len(custom_rl_env.base_command) > 1: + if event.input.name == "I": + custom_rl_env.base_command["1"] = [1, 0, 0] + if event.input.name == "K": + custom_rl_env.base_command["1"] = [-1, 0, 0] + if event.input.name == "J": + custom_rl_env.base_command["1"] = [0, 1, 0] + if event.input.name == "L": + custom_rl_env.base_command["1"] = [0, -1, 0] + if event.input.name == "U": + custom_rl_env.base_command["1"] = [0, 0, 1] + if event.input.name == "O": + custom_rl_env.base_command["1"] = [0, 0, -1] + elif event.type == carb.input.KeyboardEventType.KEY_RELEASE: + for i in range(len(custom_rl_env.base_command)): + custom_rl_env.base_command[str(i)] = [0, 0, 0] + return True + + +def move_copter(copter): + # TODO tmp solution for test + if custom_rl_env.base_command["0"] == [0, 0, 0]: + copter_move_cmd = torch.tensor([[0.0, 0.0, 0.0, 0.0, 0.0, 0.0]], device="cuda:0") + + if custom_rl_env.base_command["0"] == [1, 0, 0]: + copter_move_cmd = torch.tensor([[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]], device="cuda:0") + + if custom_rl_env.base_command["0"] == [-1, 0, 0]: + copter_move_cmd = torch.tensor([[-1.0, 0.0, 0.0, 0.0, 0.0, 0.0]], device="cuda:0") + + if custom_rl_env.base_command["0"] == [0, 1, 0]: + copter_move_cmd = torch.tensor([[0.0, 1.0, 0.0, 0.0, 0.0, 0.0]], device="cuda:0") + + if custom_rl_env.base_command["0"] == [0, -1, 0]: + copter_move_cmd = torch.tensor([[0.0, -1.0, 0.0, 0.0, 0.0, 0.0]], device="cuda:0") + + if custom_rl_env.base_command["0"] == [0, 0, 1]: + copter_move_cmd = torch.tensor([[0.0, 0.0, 1.0, 0.0, 0.0, 0.0]], device="cuda:0") + + if custom_rl_env.base_command["0"] == [0, 0, -1]: + copter_move_cmd = torch.tensor([[0.0, 0.0, -1.0, 0.0, 0.0, 0.0]], device="cuda:0") + + copter.write_root_velocity_to_sim(copter_move_cmd) + copter.write_data_to_sim() + + +def setup_custom_env(): + try: + if args_cli.custom_env == "warehouse": + cfg_scene = sim_utils.UsdFileCfg(usd_path="./envs/warehouse.usd") + cfg_scene.func("/World/warehouse", cfg_scene, translation=(0.0, 0.0, 0.0)) + + if args_cli.custom_env == "office": + cfg_scene = sim_utils.UsdFileCfg(usd_path="./envs/office.usd") + cfg_scene.func("/World/office", cfg_scene, translation=(0.0, 0.0, 0.0)) + + if args_cli.custom_env == "small_warehouse": + assets_root_path = get_assets_root_path() + cfg_scene = sim_utils.UsdFileCfg( + usd_path=assets_root_path + + "/Isaac/Environments/Digital_Twin_Warehouse/small_warehouse_digital_twin.usd" + ) + cfg_scene.func("/World/small_warehouse", cfg_scene, translation=(2.5, -1.5, 0.0)) + + if args_cli.custom_env == "hospital": + cfg_scene = sim_utils.UsdFileCfg(usd_path="./envs/hospital.usd") + cfg_scene.func("/World/hospital", cfg_scene, translation=(0.0, 0.0, 0.0)) + except Exception as e: + print( + f"Error loading custom environment. You should download custom envs folder from: https://drive.google.com/drive/folders/1vVGuO1KIX1K6mD6mBHDZGm9nk2vaRyj3?usp=sharing : {e}" + ) + + +def cmd_vel_cb(msg, num_robot): + x = msg.linear.x + y = msg.linear.y + z = msg.angular.z + custom_rl_env.base_command[str(num_robot)] = [x, y, z] + + +def add_cmd_sub(num_envs): + node_test = rclpy.create_node("position_velocity_publisher") + for i in range(num_envs): + node_test.create_subscription( + Twist, f"robot{i}/cmd_vel", lambda msg, i=i: cmd_vel_cb(msg, str(i)), 10 + ) + # Spin in a separate thread + thread = threading.Thread(target=rclpy.spin, args=(node_test,), daemon=True) + thread.start() + + +def specify_cmd_for_robots(numv_envs): + for i in range(numv_envs): + custom_rl_env.base_command[str(i)] = [0, 0, 0] + + +def run_sim(): + # acquire input interface + _input = carb.input.acquire_input_interface() + _appwindow = omni.appwindow.get_default_app_window() + _keyboard = _appwindow.get_keyboard() + _sub_keyboard = _input.subscribe_to_keyboard_events(_keyboard, sub_keyboard_event) + + """Play with RSL-RL agent.""" + # parse configuration + + env_cfg = UnitreeGo2CustomEnvCfg() + + if args_cli.robot == "g1": + env_cfg = G1RoughEnvCfg() + + # TODO need to think about better copter integration. + # copter_cfg = CRAZYFLIE_CFG + # copter_cfg.spawn.func( + # "/World/Crazyflie/Robot_1", copter_cfg.spawn, translation=(1.5, 0.5, 2.42) + # ) + + # # create handles for the robots + # copter = Articulation(copter_cfg.replace(prim_path="/World/Crazyflie/Robot.*")) + + # add N robots to env + env_cfg.scene.num_envs = args_cli.robot_amount + + specify_cmd_for_robots(env_cfg.scene.num_envs) + + agent_cfg: RslRlOnPolicyRunnerCfg = unitree_go2_agent_cfg + + if args_cli.robot == "g1": + agent_cfg: RslRlOnPolicyRunnerCfg = unitree_g1_agent_cfg + + # create isaac environment + env = gym.make(args_cli.task, cfg=env_cfg) + # wrap around environment for rsl-rl + env = RslRlVecEnvWrapper(env) + + # specify directory for logging experiments + log_root_path = os.path.join("logs", "rsl_rl", agent_cfg["experiment_name"]) + log_root_path = os.path.abspath(log_root_path) + print(f"[INFO] Loading experiment from directory: {log_root_path}") + + resume_path = get_checkpoint_path( + log_root_path, agent_cfg["load_run"], agent_cfg["load_checkpoint"] + ) + + # load previously trained model + ppo_runner = OnPolicyRunner(env, agent_cfg, log_dir=None, device=agent_cfg["device"]) + ppo_runner.load(resume_path) + print(f"[INFO]: Loading model checkpoint from: {resume_path}") + + # obtain the trained policy for inference + policy = ppo_runner.get_inference_policy(device=env.unwrapped.device) + + # reset environment + obs = env.get_observations() + + # initialize ROS2 node + rclpy.init() + base_node = RobotBaseNode(env_cfg.scene.num_envs) + add_cmd_sub(env_cfg.scene.num_envs) + + UnitreeL1_annotator_lst = add_rtx_lidar( + env_cfg.scene.num_envs, args_cli.robot, "UnitreeL1", False + ) + ExtraLidar_annotator_lst = add_rtx_lidar(env_cfg.scene.num_envs, args_cli.robot, "Extra", False) + annotator_lst = UnitreeL1_annotator_lst + ExtraLidar_annotator_lst + add_camera(env_cfg.scene.num_envs, args_cli.robot) + # add_copter_camera() + + # create ros2 camera stream omnigraph + for i in range(env_cfg.scene.num_envs): + create_front_cam_omnigraph(i) + + setup_custom_env() + + # simulate environment + while simulation_app.is_running(): + # run everything in inference mode + with torch.inference_mode(): + # agent stepping + actions = policy(obs) + # env stepping + obs, _, _, _ = env.step(actions) + pub_robo_data_ros2( + args_cli.robot, + env_cfg.scene.num_envs, + base_node, + env, + annotator_lst, + ) + # move_copter(copter) + + env.close() diff --git a/examples/4_unitree_go2/real_robot/README.md b/examples/4_unitree_go2/real_robot/README.md new file mode 100644 index 0000000..d5fa0c8 --- /dev/null +++ b/examples/4_unitree_go2/real_robot/README.md @@ -0,0 +1,249 @@ +# Example - Unitree GO2 (Real) +![Static Badge](https://img.shields.io/badge/ROS-Available-green) +![Static Badge](https://img.shields.io/badge/ROS2-Available-green) + +This is an example using the real robot, **Unitree GO2**, in the ROS2 ecosystem. If needed, it can also be run on ROS1. + +## Prerequisites + +In this example, the **Unitree GO2 Edu** model was used, and the setup was tested on **Ubuntu 20.04 with ROS2 Foxy**. If needed, it can also be run on **Ubuntu 18.04 with ROS1 Noetic**. + +โš ๏ธ **Note**: For the Unitree GO2 Air and Pro models, SSH access may be restricted, so using the Edu model is recommended whenever possible. + +### Unitree GO2 +For more details, please refer to the [Unitree GO2 Documentation](https://support.unitree.com/home/en/developer/about_Go2). + + + + +## Quick Start +### 1. Network Setup + +#### 1.1 Assigning static IP to the robot though router configuration: + +The Unitree GO2 and the userโ€™s PC can be connected via SSH through the [GO2 Robot Interface](https://www.docs.quadruped.de/projects/go2/html/go2_driver.html#go2-network-interface). +However, by default, the Unitree GO2 does not have internet access, so you need to configure the router as described below to allow the Unitree GO2 to connect to the internet. + +#### Requirements +- A router with Internet access +- A computer connected to the router via Wi-Fi or Ethernet +- Unitree GO2 connected to the router via Ethernet cable + +#### Steps +- **Access the Router Gateway** + - Make sure your PC is connected to the router. + - Run `ifconfig` in the terminal to check the assigned IP address. In this example, the IP is `192.168.50.13`. + + + - If the IP is in the form `192.168.x.x`, replace the last number with `1` and open it in a browser (e.g., `192.168.x.1`). In this case, it is `192.168.50.1`. + + +- **Login to the Router** + - Enter the default username and password of the router. + - If the credentials were previously changed, use the updated account information. + - After a successful login, the router configuration page will be displayed. + +- **Change Router Gateway Address** + - Navigate to **Advanced Settings โ†’ LAN** (the menu name may vary depending on the manufacturer/model). + - Change the routerโ€™s IP address to `192.168.123.1`. + + + - Reason: The Unitree Go2 robot has a fixed IP address of `192.168.123.18`, so the router must assign addresses in the same subnet `192.168.123.x` to enable communication. + +- **Assign a Static IP** + - By default, the router uses DHCP to dynamically assign IP addresses. + - However, after disconnection/reconnection, the Unitree Go2โ€™s IP may change. To prevent this, configure a **static IP assignment**. + - Go to the **DHCP menu**. + - Enable **Manual Assignment**, enter the Unitree Go2โ€™s **MAC address**, and set the IP address to `192.168.123.18`. + + + - Note: Most routers can automatically detect the MAC address (depending on the model). + +--- + +#### 1.2 Assigning Dynamic IP + +**Note**: Use the following method when you **do not have** admin access to the router + +**Steps**: +- **Connect your robot to the computer:** + + - Connect the `Unitree GO2` to your computer via ethernet + - Go to your network settings, and the select the one where the robot is connected. + - Inside the `TCP/IP` section, change the IP Address to `192.168.123.100` with a subnet mask of `255.255.255.0` + - Use `ifconfig/ipconfig`. The robot should be connected to your computer at the IP address `192.168.123.18` + + - Connect to the robot at: + + ``` + ssh unitree@192.168.123.18 + ``` + +- **Connect your robot to the shared Wi-Fi:** + + - Open the following file inside the terminal: + ``` + sudo nano /etc/wpa_supplicant/wpa_supplicant.conf + ``` + - Add the following: + ``` + ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev + update_config=1 + country=IN + + network={ + ssid="Wifi Name" + psk="Password" + } + ``` + + - Reset the connection to `wlan0` to connect to wifi: + ``` + sudo pkill wpa_supplicant + sudo rm -rf /var/run/wpa_supplicant/wlan0 + sudo ip link set wlan0 down + sudo ip link set wlan0 up + sudo wpa_supplicant -B -i wlan0 -c /etc/wpa_supplicant/wpa_supplicant.conf + sudo dhclient -v wlan0 + ``` + + **Tip:** You add the following commands in an executable shell script to avoid running the commands everytime manually + + - The robot should be assigned with a dynamic IP in the format `10.x.x.x` + - `SSH` into the robot using the assigned IP in a new terminal + + - After a successful connection you can remove the ethernet from your computer and the robot + + +- **Check Connectivity** + - After completing the above steps, check the connectivity between your PC and the Unitree Go2. + - You can use the `ping` command in the terminal to verify the connection: + ``` + ping 192.168.123.18 + ``` + - If you receive replies, the connection is successful. + +- **SSH Access** + - You can access the Unitree Go2 via SSH using the following command: + ``` + ssh -X unitree@192.168.123.18 + ``` + or + ``` + ssh -X unitree@10.x.x.x #USE YOUR ASSIGNED IP + ``` + + - The default password is `123`. + - After logging in, you can select the ROS version (ROS1 Noetic or ROS2 Foxy). In this example, Select ROS2 Foxy. + + + +- **Check Internet Access** + - Verify that the Unitree Go2 has internet access by running: + ``` + ping google.com + ``` + - If you receive replies, the internet connection is working properly. + + +--- + +### 2. Install rosbridge (Unitree GO2 side) +On the Unitree GO2, install rosbridge to enable communication with the **ros-mcp-server**. +```bash +sudo apt install ros-foxy-rosbridge-server +``` + +### 3. Choose and Build Control Package +You have two options for controlling the robot. + +### Option A: Using the `go2_ws` workspace +The `go2_ws` workspace exposes the different GO2 sports mode functions as ROS2 services, which the MCP can call to control the robot. + +Examples of different actions as service nodes: +- **Posture**: `/go2/stand_up`, `/go2/sit`, `/go2/recovery`, `/go2/balance` +- **Movement**: `/go2/move` (Handles velocity *and* distance/duration automatically) +- **Tricks**: `/go2/dance1`, `/go2/front_flip`, `/go2/hello`, `/go2/stretch` + +**[See detailed setup instructions in go2_ws/README.md](./go2_ws/README.md)** + +**Setup Summary:** +1. Install dependencies (`unitree_sdk2py`). +2. Copy the `go2_ws` folder to the robot. +3. Build the workspace on the robot using `colcon build`. + +#### Option B: Standard `unitree_go` Package +[`unitree_go`](https://github.com/unitreerobotics/unitree_ros2) is the official ROS2 package provided by Unitree Robotics. It provides low-level control and standard topics but fewer high-level "skills" out of the box. + +To build the `unitree_go` package, follow these steps: +- **clone the repository** (User PC side) + ```bash + git clone https://github.com/unitreerobotics/unitree_ros2.git + ``` +- **move the package to the Unitree GO2's workspace** (User PC side) + ```bash + cd unitree_ros2/cyclonedds_ws/src/ + scp -r unitree unitree@192.168.123.18:/home/unitree/cyclonedds_ws/src/ + ``` +- **build the package** (Unitree GO2 side) + ```bash + cd ~/cyclonedds_ws + colcon build --symlink-install + ``` +- **source the workspace** (Unitree GO2 side) + ```bash + source ~/.bashrc + ``` + +--- + +### 4. Setup Camera Script (User PC side) +By default, the Unitree GO2 publishes camera data through a custom topic called `/frontvideostream`. To convert this custom topic into a standard ROS2 topic, you can upload and run the following script on the Unitree GO2. + +- **move the script to the Unitree GO2** (User PC side) + ```bash + cd //ros-mcp-server/examples/4_unitree_go2/real_robot/scripts + scp ./camera_bridge.py unitree@192.168.123.18:/home/unitree/cyclonedds_ws/src/image_process/ + ``` +- **check the script** (Unitree GO2 side) + ```bash + cd ~/cyclonedds_ws/src/image_process/ + python3 camera_bridge.py + ``` + If the camera is working properly, you should see the camera feed from the `/camera/rgb/image_raw` topic. + +--- + +### 5. Launch rosbridge and camera script (Unitree GO2 side) +To enable communication between the **Unitree GO2** and the **ros-mcp-server**, you need to launch the following commands: + +On one terminal, run: +```bash +# Launch rosbridge +ros2 launch rosbridge_server rosbridge_websocket_launch.xml +``` +On a separate terminal, run: +```bash +# Launch camera script +cd ~/cyclonedds_ws/src/image_process/ +python3 camera_bridge.py +``` + +## **Integration with MCP Server** + +Once rosbridge is running on the Unitree GO2 and your PC is on the same network, you can connect the MCP server to control the robot. If you havenโ€™t set up the MCP server yet, follow theย [installation guide](https://github.com/robotmcp/ros-mcp-server/blob/main/docs/install/installation.md)ย . + +Since The **ros-mcp-server** to recognize the robot, configure it to connect to the robotโ€™s IP address. + +### **Example 1** : Connect to robot +By default, the **ros-mcp-server** can access the Unitree GO2 robot on the same network as the user's local PC. Therefore, the IP address of the Unitree GO2 robot is `192.168.123.18`. + + +### **Example 2** : Check available topics + + +### **Example 3** : Receive camera data and analyze it + + +## **Next Steps** +The Unitree GO2 has various sensors and functionalities. Let's make use of them. \ No newline at end of file diff --git a/examples/4_unitree_go2/real_robot/go2_ws/README.md b/examples/4_unitree_go2/real_robot/go2_ws/README.md new file mode 100644 index 0000000..b3728a4 --- /dev/null +++ b/examples/4_unitree_go2/real_robot/go2_ws/README.md @@ -0,0 +1,333 @@ +# Go2 ROS 2 Control Workspace + +Complete ROS 2 workspace for controlling the Unitree Go2 quadruped robot via custom services. + +## Workspace Structure + +``` +go2_ws/ +โ”œโ”€โ”€ src/ +โ”‚ โ”œโ”€โ”€ go2_interfaces/ # Custom service definitions +โ”‚ โ”‚ โ”œโ”€โ”€ CMakeLists.txt +โ”‚ โ”‚ โ”œโ”€โ”€ package.xml +โ”‚ โ”‚ โ””โ”€โ”€ srv/ +โ”‚ โ”‚ โ”œโ”€โ”€ Trigger.srv # Simple trigger (no input) +โ”‚ โ”‚ โ”œโ”€โ”€ SetBool.srv # Enable/disable features +โ”‚ โ”‚ โ”œโ”€โ”€ Move.srv # Movement with velocity + distance +โ”‚ โ”‚ โ”œโ”€โ”€ SetFloat.srv # Float parameter setting +โ”‚ โ”‚ โ””โ”€โ”€ SetInt.srv # Integer parameter setting +โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€ go2_control/ # Main control package +โ”‚ โ”œโ”€โ”€ package.xml +โ”‚ โ”œโ”€โ”€ setup.py +โ”‚ โ”œโ”€โ”€ setup.cfg +โ”‚ โ”œโ”€โ”€ resource/ +โ”‚ โ”‚ โ””โ”€โ”€ go2_control +โ”‚ โ””โ”€โ”€ go2_control/ +โ”‚ โ”œโ”€โ”€ __init__.py +โ”‚ โ”œโ”€โ”€ go2_backend.py # SDK communication (separate process) +โ”‚ โ””โ”€โ”€ go2_service_node.py # ROS 2 services +``` + +## Build Instructions + +```bash +cd ~/go2_ws +colcon build --symlink-install +source install/setup.bash +``` + +## Running + +```bash +source ~/go2_ws/install/setup.bash +ros2 run go2_control go2_services +``` + +--- + +## Complete Service Reference + +### Service Types + +| Type | Description | Request Fields | +|------|-------------|----------------| +| `Trigger` | Simple actions | (none) | +| `SetBool` | Toggle features | `enable: bool` | +| `Move` | Movement control | `vx, vy, vyaw, distance` | +| `SetFloat` | Float parameters | `value: float64` | +| `SetInt` | Integer parameters | `value: int32` | + +--- + +### POSTURE SERVICES (Trigger) + +| Service | Description | +|---------|-------------| +| `/go2/damp` | **Emergency stop** - Enter damping state (highest priority) | +| `/go2/balance` | Enter balance stand mode (maintains posture on uneven terrain) | +| `/go2/stop` | Stop current action, restore parameters to defaults | +| `/go2/stand_up` | Stand up to high stance (0.33m default height) | +| `/go2/stand_down` | Lie down to low stance | +| `/go2/recovery` | Recovery stand - use after robot falls or is in unknown state | +| `/go2/sit` | Sit down (special sitting posture) | +| `/go2/rise_sit` | Stand up from sitting position | + +```bash +# Examples +ros2 service call /go2/stand_up go2_interfaces/srv/Trigger +ros2 service call /go2/sit go2_interfaces/srv/Trigger +ros2 service call /go2/recovery go2_interfaces/srv/Trigger +``` + +--- + +### MOVEMENT SERVICE (Move) + +**Service:** `/go2/move` + +**Request Fields:** +- `vx` (float64): Forward velocity in m/s (range: -2.5 to 3.8) +- `vy` (float64): Lateral velocity in m/s (range: -1.0 to 1.0) +- `vyaw` (float64): Angular velocity in rad/s (range: -4.0 to 4.0) +- `distance` (float64): Distance in meters OR angle in radians + +**Response Fields:** +- `success` (bool): Command accepted +- `message` (string): Execution details +- `estimated_time` (float64): Estimated completion time in seconds +- `iterations` (int32): Number of control iterations + +**Note:** The robot automatically enters walking mode when needed. No need to call a separate "free_walk" service. + +```bash +# Move forward 1 meter at 0.3 m/s +ros2 service call /go2/move go2_interfaces/srv/Move "{vx: 0.3, vy: 0.0, vyaw: 0.0, distance: 1.0}" + +# Move backward 0.5 meters +ros2 service call /go2/move go2_interfaces/srv/Move "{vx: -0.3, vy: 0.0, vyaw: 0.0, distance: 0.5}" + +# Strafe left 1 meter +ros2 service call /go2/move go2_interfaces/srv/Move "{vx: 0.0, vy: 0.2, vyaw: 0.0, distance: 1.0}" + +# Strafe right 0.5 meters +ros2 service call /go2/move go2_interfaces/srv/Move "{vx: 0.0, vy: -0.2, vyaw: 0.0, distance: 0.5}" + +# Turn left 90 degrees (ฯ€/2 โ‰ˆ 1.57 radians) +ros2 service call /go2/move go2_interfaces/srv/Move "{vx: 0.0, vy: 0.0, vyaw: 0.5, distance: 1.57}" + +# Turn right 180 degrees (ฯ€ โ‰ˆ 3.14 radians) +ros2 service call /go2/move go2_interfaces/srv/Move "{vx: 0.0, vy: 0.0, vyaw: -0.5, distance: 3.14}" + +# Diagonal movement (forward-left) +ros2 service call /go2/move go2_interfaces/srv/Move "{vx: 0.2, vy: 0.2, vyaw: 0.0, distance: 1.0}" +``` + +--- + +### BODY CONFIGURATION SERVICES + +#### `/go2/body_height` (SetFloat) +Set body height relative to default (0.33m). +- Range: -0.18 to 0.03 meters +- Example: `-0.1` sets height to 0.23m + +```bash +ros2 service call /go2/body_height go2_interfaces/srv/SetFloat "{value: -0.1}" +``` + +#### `/go2/foot_raise_height` (SetFloat) +Set foot raise height relative to default (0.09m). +- Range: -0.06 to 0.03 meters + +```bash +ros2 service call /go2/foot_raise_height go2_interfaces/srv/SetFloat "{value: 0.02}" +``` + +#### `/go2/speed_level` (SetInt) +Set speed range. +- `-1` = Slow +- `0` = Normal +- `1` = Fast + +```bash +ros2 service call /go2/speed_level go2_interfaces/srv/SetInt "{value: 1}" +``` + +#### `/go2/switch_gait` (SetInt) +Switch gait mode. +- `0` = Idle +- `1` = Trot +- `2` = Trot running +- `3` = Forward climbing mode +- `4` = Reverse climbing mode + +```bash +ros2 service call /go2/switch_gait go2_interfaces/srv/SetInt "{value: 1}" +``` + +--- + +### TOGGLE SERVICES (SetBool) + +| Service | Description | +|---------|-------------| +| `/go2/switch_joystick` | Enable/disable native remote control response | +| `/go2/continuous_gait` | Enable/disable continuous gait (maintains walking even at zero velocity) | +| `/go2/handstand` | Enable/disable handstand mode | +| `/go2/walk_upright` | Enable/disable upright walking | +| `/go2/free_avoid` | Enable/disable obstacle avoidance | +| `/go2/free_bound` | Enable/disable bounding gait | +| `/go2/cross_step` | Enable/disable cross-step walking | +| `/go2/free_jump` | Enable/disable jumping capability | + +```bash +# Enable obstacle avoidance +ros2 service call /go2/free_avoid go2_interfaces/srv/SetBool "{enable: true}" + +# Disable remote control response +ros2 service call /go2/switch_joystick go2_interfaces/srv/SetBool "{enable: false}" + +# Enable handstand +ros2 service call /go2/handstand go2_interfaces/srv/SetBool "{enable: true}" +``` + +--- + +### GESTURE / TRICK SERVICES (Trigger) + +| Service | Description | +|---------|-------------| +| `/go2/hello` | Wave hello greeting | +| `/go2/stretch` | Stretch | +| `/go2/wallow` | Roll over | +| `/go2/pose_on` | Strike a pose | +| `/go2/pose_off` | Exit pose | +| `/go2/scrape` | New Year greeting gesture | +| `/go2/front_flip` | Front flip | +| `/go2/front_jump` | Jump forward | +| `/go2/front_pounce` | Pounce forward | +| `/go2/left_flip` | Left flip | +| `/go2/back_flip` | Back flip | +| `/go2/dance1` | Dance routine 1 | +| `/go2/dance2` | Dance routine 2 | + +```bash +# Say hello +ros2 service call /go2/hello go2_interfaces/srv/Trigger + +# Dance +ros2 service call /go2/dance1 go2_interfaces/srv/Trigger + +# Front flip (caution!) +ros2 service call /go2/front_flip go2_interfaces/srv/Trigger + +# Stretch +ros2 service call /go2/stretch go2_interfaces/srv/Trigger +``` + +--- + +## Distance Calculation + +The Move service automatically calculates iterations: + +``` +iterations = ceil(distance / (velocity ร— 0.5s)) +``` + +**Example:** Moving 2 meters at 0.3 m/s +- Distance per iteration: 0.3 ร— 0.5 = 0.15 meters +- Iterations needed: ceil(2.0 / 0.15) = 14 +- Estimated time: 14 ร— 0.5 = 7.0 seconds + +--- + +## Architecture + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ LLM / User โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ”‚ ROS 2 Service Calls + โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Go2ServiceNode (ROS 2) โ”‚ +โ”‚ - Exposes /go2/* services โ”‚ +โ”‚ - Validates inputs, calculates iterations โ”‚ +โ”‚ - Sends commands via multiprocessing Queue โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ”‚ Multiprocessing Queue + โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Backend Process (SDK) โ”‚ +โ”‚ - Runs Unitree SDK (separate DDS domain) โ”‚ +โ”‚ - Executes SportClient commands โ”‚ +โ”‚ - Handles mode transitions and timing โ”‚ +โ”‚ - Automatically enters walking mode for Move commands โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ”‚ Unitree SDK + โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Go2 Robot โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +--- + +## LLM Integration Examples + +### Common Commands for LLM + +**"Stand up and walk forward 3 meters":** +```bash +ros2 service call /go2/stand_up go2_interfaces/srv/Trigger +ros2 service call /go2/move go2_interfaces/srv/Move "{vx: 0.3, vy: 0.0, vyaw: 0.0, distance: 3.0}" +``` + +**"Turn around" (180ยฐ):** +```bash +ros2 service call /go2/move go2_interfaces/srv/Move "{vx: 0.0, vy: 0.0, vyaw: 0.5, distance: 3.14}" +``` + +**"Say hello and do a dance":** +```bash +ros2 service call /go2/hello go2_interfaces/srv/Trigger +ros2 service call /go2/dance1 go2_interfaces/srv/Trigger +``` + +**"Sit down":** +```bash +ros2 service call /go2/sit go2_interfaces/srv/Trigger +``` + +--- + +## Troubleshooting + +### "Service not found" +```bash +source ~/go2_ws/install/setup.bash +``` + +### "Module not found: go2_interfaces" +Rebuild and source: +```bash +cd ~/go2_ws +colcon build --packages-select go2_interfaces +source install/setup.bash +colcon build --packages-select go2_control +source install/setup.bash +``` + +### Clock skew warnings +```bash +sudo timedatectl set-ntp true +# or +sudo ntpdate ntp.ubuntu.com +``` + +These warnings don't affect functionality. diff --git a/examples/4_unitree_go2/real_robot/go2_ws/src/go2_control/go2_control/__init__.py b/examples/4_unitree_go2/real_robot/go2_ws/src/go2_control/go2_control/__init__.py new file mode 100644 index 0000000..885547a --- /dev/null +++ b/examples/4_unitree_go2/real_robot/go2_ws/src/go2_control/go2_control/__init__.py @@ -0,0 +1,2 @@ +# go2_control package +# ROS 2 services for Unitree Go2 robot control diff --git a/examples/4_unitree_go2/real_robot/go2_ws/src/go2_control/go2_control/go2_backend.py b/examples/4_unitree_go2/real_robot/go2_ws/src/go2_control/go2_control/go2_backend.py new file mode 100644 index 0000000..2984eed --- /dev/null +++ b/examples/4_unitree_go2/real_robot/go2_ws/src/go2_control/go2_control/go2_backend.py @@ -0,0 +1,322 @@ +""" +Go2 Backend Module +================== +Handles direct communication with the Unitree Go2 robot via the SDK. + +This module runs in a SEPARATE PROCESS from the ROS 2 node because: +1. Unitree SDK uses its own DDS implementation (CycloneDDS) +2. ROS 2 also uses DDS (FastDDS by default) +3. Running both in the same process causes conflicts + +Architecture: + [ROS 2 Node] --Queue--> [Backend Process] --SDK--> [Go2 Robot] +""" + +import time +from multiprocessing import Queue + +from unitree_sdk2py.core.channel import ChannelFactoryInitialize +from unitree_sdk2py.go2.sport.sport_client import SportClient + +# Time interval between Move() calls (robot requires this delay) +MOVE_INTERVAL = 0.5 # seconds + + +def backend_loop(cmd_queue: Queue): + """ + Main backend loop - runs in separate process. + + Continuously reads commands from the queue and executes them + on the robot via the Unitree SDK. + + Args: + cmd_queue: Multiprocessing queue receiving command dictionaries + """ + + # ================================================================ + # SDK INITIALIZATION + # ================================================================ + + ChannelFactoryInitialize(0) + + sc = SportClient() + sc.SetTimeout(10.0) + sc.Init() + + # ================================================================ + # STATE TRACKING + # ================================================================ + + walking = False # Is robot currently in walking mode? + last_mode_time = 0.0 # Timestamp of last mode change + + def mode_guard(): + """ + Enforce minimum delay between mode-changing commands. + The robot needs time to transition between modes. + Minimum gap: 0.5 seconds + """ + nonlocal last_mode_time + elapsed = time.time() - last_mode_time + if elapsed < 0.5: + time.sleep(0.5 - elapsed) + last_mode_time = time.time() + + print("=" * 60) + print("[Go2 Backend] Initialized and ready for commands") + print("=" * 60) + + # ================================================================ + # MAIN COMMAND LOOP + # ================================================================ + + while True: + cmd = cmd_queue.get() + + if cmd is None: + print("[Go2 Backend] Received shutdown signal") + break + + ctype = cmd.get("type", "") + + try: + # -------------------------------------------------------- + # POSTURE / STATE COMMANDS + # -------------------------------------------------------- + + if ctype == "damp": + mode_guard() + sc.Damp() + walking = False + print("[Backend] Executed: Damp (emergency stop)") + + elif ctype == "balance": + mode_guard() + sc.BalanceStand() + walking = False + print("[Backend] Executed: BalanceStand") + + elif ctype == "stop": + mode_guard() + sc.StopMove() + walking = False + print("[Backend] Executed: StopMove") + + elif ctype == "stand_up": + mode_guard() + sc.StandUp() + walking = False + print("[Backend] Executed: StandUp") + + elif ctype == "stand_down": + mode_guard() + sc.StandDown() + walking = False + print("[Backend] Executed: StandDown") + + elif ctype == "recovery": + mode_guard() + sc.RecoveryStand() + walking = False + print("[Backend] Executed: RecoveryStand") + + elif ctype == "sit": + mode_guard() + sc.Sit() + walking = False + print("[Backend] Executed: Sit") + + elif ctype == "rise_sit": + mode_guard() + sc.RiseSit() + walking = False + print("[Backend] Executed: RiseSit") + + # -------------------------------------------------------- + # MOVEMENT COMMAND + # -------------------------------------------------------- + + elif ctype == "move": + vx = cmd.get("vx", 0.0) + vy = cmd.get("vy", 0.0) + wz = cmd.get("wz", 0.0) + iterations = cmd.get("iterations", 1) + + # Internally enable walking mode if not already + if not walking: + mode_guard() + sc.FreeWalk() + walking = True + + print( + f"[Backend] Moving: vx={vx:.2f}, vy={vy:.2f}, wz={wz:.2f} for {iterations} iterations" + ) + + for i in range(iterations): + sc.Move(vx, vy, wz) + time.sleep(MOVE_INTERVAL) + + # Stop after completing movement + sc.Move(0.0, 0.0, 0.0) + print("[Backend] Move complete") + + # -------------------------------------------------------- + # BODY CONFIGURATION COMMANDS + # -------------------------------------------------------- + + elif ctype == "euler": + roll = cmd.get("roll", 0.0) + pitch = cmd.get("pitch", 0.0) + yaw = cmd.get("yaw", 0.0) + mode_guard() + sc.Euler(roll, pitch, yaw) + print(f"[Backend] Executed: Euler(roll={roll}, pitch={pitch}, yaw={yaw})") + + elif ctype == "body_height": + height = cmd.get("height", 0.0) + mode_guard() + sc.BodyHeight(height) + print(f"[Backend] Executed: BodyHeight({height})") + + elif ctype == "foot_raise_height": + height = cmd.get("height", 0.0) + mode_guard() + sc.FootRaiseHeight(height) + print(f"[Backend] Executed: FootRaiseHeight({height})") + + elif ctype == "speed_level": + level = cmd.get("level", 0) + mode_guard() + sc.SpeedLevel(level) + print(f"[Backend] Executed: SpeedLevel({level})") + + elif ctype == "switch_gait": + gait = cmd.get("gait", 0) + mode_guard() + sc.SwitchGait(gait) + print(f"[Backend] Executed: SwitchGait({gait})") + + # -------------------------------------------------------- + # TOGGLE COMMANDS (enable/disable) + # -------------------------------------------------------- + + elif ctype == "switch_joystick": + enable = cmd.get("enable", True) + mode_guard() + sc.SwitchJoystick(enable) + print(f"[Backend] Executed: SwitchJoystick({enable})") + + elif ctype == "continuous_gait": + enable = cmd.get("enable", False) + mode_guard() + sc.ContinuousGait(enable) + print(f"[Backend] Executed: ContinuousGait({enable})") + + elif ctype == "handstand": + enable = cmd.get("enable", False) + mode_guard() + sc.HandStand(enable) + print(f"[Backend] Executed: HandStand({enable})") + + elif ctype == "walk_upright": + enable = cmd.get("enable", False) + mode_guard() + sc.WalkUpright(enable) + print(f"[Backend] Executed: WalkUpright({enable})") + + elif ctype == "free_avoid": + enable = cmd.get("enable", False) + mode_guard() + sc.FreeAvoid(enable) + print(f"[Backend] Executed: FreeAvoid({enable})") + + elif ctype == "free_bound": + enable = cmd.get("enable", False) + mode_guard() + sc.FreeBound(enable) + print(f"[Backend] Executed: FreeBound({enable})") + + elif ctype == "cross_step": + enable = cmd.get("enable", False) + mode_guard() + sc.CrossStep(enable) + print(f"[Backend] Executed: CrossStep({enable})") + + elif ctype == "free_jump": + enable = cmd.get("enable", False) + mode_guard() + sc.FreeJump(enable) + print(f"[Backend] Executed: FreeJump({enable})") + + # -------------------------------------------------------- + # GESTURE / TRICK COMMANDS (one-shot actions) + # -------------------------------------------------------- + + elif ctype == "hello": + mode_guard() + sc.Hello() + print("[Backend] Executed: Hello (greeting)") + + elif ctype == "stretch": + mode_guard() + sc.Stretch() + print("[Backend] Executed: Stretch") + + elif ctype == "wallow": + mode_guard() + sc.Wallow() + print("[Backend] Executed: Wallow (rolling)") + + elif ctype == "pose": + enable = cmd.get("enable", True) + mode_guard() + sc.Pose(enable) + print(f"[Backend] Executed: Pose({enable})") + + elif ctype == "scrape": + mode_guard() + sc.Scrape() + print("[Backend] Executed: Scrape (New Year greeting)") + + elif ctype == "front_flip": + mode_guard() + sc.FrontFlip() + print("[Backend] Executed: FrontFlip") + + elif ctype == "front_jump": + mode_guard() + sc.FrontJump() + print("[Backend] Executed: FrontJump") + + elif ctype == "front_pounce": + mode_guard() + sc.FrontPounce() + print("[Backend] Executed: FrontPounce") + + elif ctype == "left_flip": + mode_guard() + sc.LeftFlip() + print("[Backend] Executed: LeftFlip") + + elif ctype == "back_flip": + mode_guard() + sc.BackFlip() + print("[Backend] Executed: BackFlip") + + elif ctype == "dance1": + mode_guard() + sc.Dance1() + print("[Backend] Executed: Dance1") + + elif ctype == "dance2": + mode_guard() + sc.Dance2() + print("[Backend] Executed: Dance2") + + else: + print(f"[Backend] WARNING: Unknown command type: {ctype}") + + except Exception as e: + print(f"[Backend] ERROR executing {ctype}: {e}") + + print("[Go2 Backend] Shutdown complete") diff --git a/examples/4_unitree_go2/real_robot/go2_ws/src/go2_control/go2_control/go2_service_node.py b/examples/4_unitree_go2/real_robot/go2_ws/src/go2_control/go2_control/go2_service_node.py new file mode 100644 index 0000000..1b0afe9 --- /dev/null +++ b/examples/4_unitree_go2/real_robot/go2_ws/src/go2_control/go2_control/go2_service_node.py @@ -0,0 +1,618 @@ +""" +Go2 ROS 2 Service Node +====================== +Exposes all Go2 robot capabilities as individual ROS 2 services. + +Custom service types from go2_interfaces: +- Trigger : Simple actions (no input) +- SetBool : Enable/disable features +- Move : Movement with velocity + distance +- SetFloat : Float parameter setting +- SetInt : Integer parameter setting + +SERVICES PROVIDED: +================== + +POSTURE SERVICES (Trigger): + /go2/damp - Emergency stop, enter damping state (highest priority) + /go2/balance - Enter balance stand mode + /go2/stop - Stop current action, restore defaults + /go2/stand_up - Stand up (high stance, 0.33m) + /go2/stand_down - Lie down (low stance) + /go2/recovery - Recovery stand (use after falls) + /go2/sit - Sit down (special action) + /go2/rise_sit - Stand up from sitting + +MOVEMENT SERVICE (Move): + /go2/move - Move robot with velocity and distance control + Input: vx (m/s), vy (m/s), vyaw (rad/s), distance (m or rad) + Note: Automatically enters walking mode if needed + +BODY CONFIGURATION SERVICES: + /go2/euler - Set body posture (roll, pitch, yaw in radians) + /go2/body_height - Set body height relative to default (SetFloat, range: -0.18 to 0.03m) + /go2/foot_raise_height - Set foot raise height relative to default (SetFloat, range: -0.06 to 0.03m) + /go2/speed_level - Set speed range (SetInt: -1=slow, 0=normal, 1=fast) + /go2/switch_gait - Switch gait (SetInt: 0=idle, 1=trot, 2=trot running, 3=forward climb, 4=reverse climb) + +TOGGLE SERVICES (SetBool): + /go2/switch_joystick - Enable/disable native remote control response + /go2/continuous_gait - Enable/disable continuous gait mode + /go2/handstand - Enable/disable handstand + /go2/walk_upright - Enable/disable upright walking + /go2/free_avoid - Enable/disable obstacle avoidance + /go2/free_bound - Enable/disable bounding gait + /go2/cross_step - Enable/disable cross-step walking + /go2/free_jump - Enable/disable jumping + +GESTURE / TRICK SERVICES (Trigger): + /go2/hello - Say hello (wave) + /go2/stretch - Stretch + /go2/wallow - Roll over + /go2/pose_on - Strike a pose + /go2/pose_off - Exit pose + /go2/scrape - New Year greeting + /go2/front_flip - Front flip + /go2/front_jump - Jump forward + /go2/front_pounce - Pounce forward + /go2/left_flip - Left flip + /go2/back_flip - Back flip + /go2/dance1 - Dance routine 1 + /go2/dance2 - Dance routine 2 + +EXAMPLE SERVICE CALLS (for LLM reference): +========================================== + +# Stand up +ros2 service call /go2/stand_up go2_interfaces/srv/Trigger + +# Move forward 2 meters at 0.3 m/s +ros2 service call /go2/move go2_interfaces/srv/Move "{vx: 0.3, vy: 0.0, vyaw: 0.0, distance: 2.0}" + +# Turn left 90 degrees (1.57 radians) at 0.5 rad/s +ros2 service call /go2/move go2_interfaces/srv/Move "{vx: 0.0, vy: 0.0, vyaw: 0.5, distance: 1.57}" + +# Say hello +ros2 service call /go2/hello go2_interfaces/srv/Trigger + +# Do a dance +ros2 service call /go2/dance1 go2_interfaces/srv/Trigger + +# Set body height lower by 0.1m +ros2 service call /go2/body_height go2_interfaces/srv/SetFloat "{value: -0.1}" + +# Set speed to fast +ros2 service call /go2/speed_level go2_interfaces/srv/SetInt "{value: 1}" +""" + +import math +from multiprocessing import Process, Queue + +import rclpy +from go2_interfaces.srv import Move, SetBool, SetFloat, SetInt, Trigger +from rclpy.node import Node + +from go2_control.go2_backend import backend_loop + +# ============================================================ +# CONFIGURATION CONSTANTS +# ============================================================ + +MOVE_INTERVAL = 0.5 # seconds between Move() calls + +# Velocity limits from SDK documentation +MAX_VX = 2.5 # m/s (can go up to 3.8 forward) +MAX_VY = 1.0 # m/s +MAX_VYAW = 4.0 # rad/s + + +def calculate_iterations(distance: float, velocity: float, interval: float = MOVE_INTERVAL) -> int: + """ + Calculate the number of move iterations needed to cover a distance. + + Formula: + distance_per_call = velocity ร— interval + iterations = ceil(distance / distance_per_call) + + Args: + distance: Target distance in meters (or radians for rotation) + velocity: Movement velocity in m/s (or rad/s for rotation) + interval: Time between Move() calls (default 0.5s) + + Returns: + Number of iterations (minimum 1) + """ + if abs(velocity) < 0.001: + return 1 + + distance_per_call = abs(velocity) * interval + iterations = math.ceil(abs(distance) / distance_per_call) + return max(1, iterations) + + +class Go2ServiceNode(Node): + """ + ROS 2 Node providing services for Go2 robot control. + """ + + def __init__(self, cmd_queue: Queue): + super().__init__("go2_services") + self.queue = cmd_queue + + # ============================================================ + # POSTURE SERVICES (Trigger type) + # ============================================================ + + self.create_service(Trigger, "/go2/damp", self.cb_damp) + self.create_service(Trigger, "/go2/balance", self.cb_balance) + self.create_service(Trigger, "/go2/stop", self.cb_stop) + self.create_service(Trigger, "/go2/stand_up", self.cb_stand_up) + self.create_service(Trigger, "/go2/stand_down", self.cb_stand_down) + self.create_service(Trigger, "/go2/recovery", self.cb_recovery) + self.create_service(Trigger, "/go2/sit", self.cb_sit) + self.create_service(Trigger, "/go2/rise_sit", self.cb_rise_sit) + + # ============================================================ + # MOVEMENT SERVICE (Move type) + # ============================================================ + + self.create_service(Move, "/go2/move", self.cb_move) + + # ============================================================ + # BODY CONFIGURATION SERVICES + # ============================================================ + + self.create_service(SetFloat, "/go2/body_height", self.cb_body_height) + self.create_service(SetFloat, "/go2/foot_raise_height", self.cb_foot_raise_height) + self.create_service(SetInt, "/go2/speed_level", self.cb_speed_level) + self.create_service(SetInt, "/go2/switch_gait", self.cb_switch_gait) + + # ============================================================ + # TOGGLE SERVICES (SetBool type) + # ============================================================ + + self.create_service(SetBool, "/go2/switch_joystick", self.cb_switch_joystick) + self.create_service(SetBool, "/go2/continuous_gait", self.cb_continuous_gait) + self.create_service(SetBool, "/go2/handstand", self.cb_handstand) + self.create_service(SetBool, "/go2/walk_upright", self.cb_walk_upright) + self.create_service(SetBool, "/go2/free_avoid", self.cb_free_avoid) + self.create_service(SetBool, "/go2/free_bound", self.cb_free_bound) + self.create_service(SetBool, "/go2/cross_step", self.cb_cross_step) + self.create_service(SetBool, "/go2/free_jump", self.cb_free_jump) + + # ============================================================ + # GESTURE / TRICK SERVICES (Trigger type) + # ============================================================ + + self.create_service(Trigger, "/go2/hello", self.cb_hello) + self.create_service(Trigger, "/go2/stretch", self.cb_stretch) + self.create_service(Trigger, "/go2/wallow", self.cb_wallow) + self.create_service(Trigger, "/go2/pose_on", self.cb_pose_on) + self.create_service(Trigger, "/go2/pose_off", self.cb_pose_off) + self.create_service(Trigger, "/go2/scrape", self.cb_scrape) + self.create_service(Trigger, "/go2/front_flip", self.cb_front_flip) + self.create_service(Trigger, "/go2/front_jump", self.cb_front_jump) + self.create_service(Trigger, "/go2/front_pounce", self.cb_front_pounce) + self.create_service(Trigger, "/go2/left_flip", self.cb_left_flip) + self.create_service(Trigger, "/go2/back_flip", self.cb_back_flip) + self.create_service(Trigger, "/go2/dance1", self.cb_dance1) + self.create_service(Trigger, "/go2/dance2", self.cb_dance2) + + # ============================================================ + # LOG AVAILABLE SERVICES + # ============================================================ + + self.get_logger().info("=" * 65) + self.get_logger().info("Go2 ROS 2 Services Ready!") + self.get_logger().info("=" * 65) + self.get_logger().info("") + self.get_logger().info("POSTURE (Trigger):") + self.get_logger().info(" /go2/damp, /go2/balance, /go2/stop, /go2/stand_up") + self.get_logger().info(" /go2/stand_down, /go2/recovery, /go2/sit, /go2/rise_sit") + self.get_logger().info("") + self.get_logger().info("MOVEMENT (Move):") + self.get_logger().info(" /go2/move - Args: vx, vy, vyaw, distance") + self.get_logger().info("") + self.get_logger().info("BODY CONFIG:") + self.get_logger().info(" /go2/body_height (SetFloat), /go2/foot_raise_height (SetFloat)") + self.get_logger().info(" /go2/speed_level (SetInt), /go2/switch_gait (SetInt)") + self.get_logger().info("") + self.get_logger().info("TOGGLES (SetBool):") + self.get_logger().info(" /go2/switch_joystick, /go2/continuous_gait, /go2/handstand") + self.get_logger().info(" /go2/walk_upright, /go2/free_avoid, /go2/free_bound") + self.get_logger().info(" /go2/cross_step, /go2/free_jump") + self.get_logger().info("") + self.get_logger().info("GESTURES/TRICKS (Trigger):") + self.get_logger().info( + " /go2/hello, /go2/stretch, /go2/wallow, /go2/pose_on, /go2/pose_off" + ) + self.get_logger().info(" /go2/scrape, /go2/front_flip, /go2/front_jump, /go2/front_pounce") + self.get_logger().info(" /go2/left_flip, /go2/back_flip, /go2/dance1, /go2/dance2") + self.get_logger().info("=" * 65) + + # ================================================================ + # POSTURE SERVICE CALLBACKS + # ================================================================ + + def cb_damp(self, request, response): + """Emergency stop - enter damping state.""" + self.queue.put({"type": "damp"}) + response.success = True + response.message = "Damp command sent (emergency stop)" + self.get_logger().info("Service: /go2/damp") + return response + + def cb_balance(self, request, response): + """Enter balance stand mode.""" + self.queue.put({"type": "balance"}) + response.success = True + response.message = "Balance stand command sent" + self.get_logger().info("Service: /go2/balance") + return response + + def cb_stop(self, request, response): + """Stop current action and restore defaults.""" + self.queue.put({"type": "stop"}) + response.success = True + response.message = "Stop command sent" + self.get_logger().info("Service: /go2/stop") + return response + + def cb_stand_up(self, request, response): + """Stand up to high stance.""" + self.queue.put({"type": "stand_up"}) + response.success = True + response.message = "Stand up command sent" + self.get_logger().info("Service: /go2/stand_up") + return response + + def cb_stand_down(self, request, response): + """Lie down to low stance.""" + self.queue.put({"type": "stand_down"}) + response.success = True + response.message = "Stand down command sent" + self.get_logger().info("Service: /go2/stand_down") + return response + + def cb_recovery(self, request, response): + """Recovery stand from fallen state.""" + self.queue.put({"type": "recovery"}) + response.success = True + response.message = "Recovery stand command sent" + self.get_logger().info("Service: /go2/recovery") + return response + + def cb_sit(self, request, response): + """Sit down.""" + self.queue.put({"type": "sit"}) + response.success = True + response.message = "Sit command sent" + self.get_logger().info("Service: /go2/sit") + return response + + def cb_rise_sit(self, request, response): + """Stand up from sitting.""" + self.queue.put({"type": "rise_sit"}) + response.success = True + response.message = "Rise from sit command sent" + self.get_logger().info("Service: /go2/rise_sit") + return response + + # ================================================================ + # MOVEMENT SERVICE CALLBACK + # ================================================================ + + def cb_move(self, request, response): + """ + Execute a movement with specified velocities and distance. + Automatically enters walking mode if needed. + """ + vx = request.vx + vy = request.vy + vyaw = request.vyaw + distance = request.distance + + # Validate inputs + if distance <= 0: + response.success = False + response.message = "Distance must be positive" + response.estimated_time = 0.0 + response.iterations = 0 + return response + + # Clamp velocities to safe limits + vx = max(-MAX_VX, min(MAX_VX, vx)) + vy = max(-MAX_VY, min(MAX_VY, vy)) + vyaw = max(-MAX_VYAW, min(MAX_VYAW, vyaw)) + + # Calculate iterations based on dominant motion + abs_vx = abs(vx) + abs_vy = abs(vy) + abs_vyaw = abs(vyaw) + + if abs_vx >= abs_vy and abs_vx >= abs_vyaw and abs_vx > 0.001: + iterations = calculate_iterations(distance, abs_vx) + motion_type = "forward" if vx > 0 else "backward" + elif abs_vy >= abs_vx and abs_vy >= abs_vyaw and abs_vy > 0.001: + iterations = calculate_iterations(distance, abs_vy) + motion_type = "left" if vy > 0 else "right" + elif abs_vyaw > 0.001: + iterations = calculate_iterations(distance, abs_vyaw) + degrees = math.degrees(distance) + motion_type = f"rotate {'left' if vyaw > 0 else 'right'} {degrees:.1f}ยฐ" + else: + response.success = False + response.message = "At least one velocity component must be non-zero" + response.estimated_time = 0.0 + response.iterations = 0 + return response + + estimated_time = iterations * MOVE_INTERVAL + + self.queue.put({"type": "move", "vx": vx, "vy": vy, "wz": vyaw, "iterations": iterations}) + + response.success = True + response.message = ( + f"Moving {motion_type}: vx={vx:.2f}m/s, vy={vy:.2f}m/s, " + f"vyaw={vyaw:.2f}rad/s, distance={distance:.2f}, " + f"iterations={iterations}, timeโ‰ˆ{estimated_time:.1f}s" + ) + response.estimated_time = estimated_time + response.iterations = iterations + + self.get_logger().info(f"Service: /go2/move - {response.message}") + return response + + # ================================================================ + # BODY CONFIGURATION CALLBACKS + # ================================================================ + + def cb_body_height(self, request, response): + """Set body height relative to default (range: -0.18 to 0.03m).""" + height = max(-0.18, min(0.03, request.value)) + self.queue.put({"type": "body_height", "height": height}) + response.success = True + response.message = f"Body height set to {height:.3f}m (relative to default 0.33m)" + self.get_logger().info(f"Service: /go2/body_height value={height}") + return response + + def cb_foot_raise_height(self, request, response): + """Set foot raise height relative to default (range: -0.06 to 0.03m).""" + height = max(-0.06, min(0.03, request.value)) + self.queue.put({"type": "foot_raise_height", "height": height}) + response.success = True + response.message = f"Foot raise height set to {height:.3f}m (relative to default 0.09m)" + self.get_logger().info(f"Service: /go2/foot_raise_height value={height}") + return response + + def cb_speed_level(self, request, response): + """Set speed level (-1=slow, 0=normal, 1=fast).""" + level = max(-1, min(1, request.value)) + self.queue.put({"type": "speed_level", "level": level}) + level_names = {-1: "slow", 0: "normal", 1: "fast"} + response.success = True + response.message = f"Speed level set to {level} ({level_names.get(level, 'unknown')})" + self.get_logger().info(f"Service: /go2/speed_level value={level}") + return response + + def cb_switch_gait(self, request, response): + """Switch gait (0=idle, 1=trot, 2=trot running, 3=forward climb, 4=reverse climb).""" + gait = max(0, min(4, request.value)) + self.queue.put({"type": "switch_gait", "gait": gait}) + gait_names = { + 0: "idle", + 1: "trot", + 2: "trot running", + 3: "forward climb", + 4: "reverse climb", + } + response.success = True + response.message = f"Gait switched to {gait} ({gait_names.get(gait, 'unknown')})" + self.get_logger().info(f"Service: /go2/switch_gait value={gait}") + return response + + # ================================================================ + # TOGGLE SERVICE CALLBACKS + # ================================================================ + + def cb_switch_joystick(self, request, response): + """Enable/disable native remote control response.""" + self.queue.put({"type": "switch_joystick", "enable": request.enable}) + response.success = True + response.message = f"Joystick response {'enabled' if request.enable else 'disabled'}" + self.get_logger().info(f"Service: /go2/switch_joystick enable={request.enable}") + return response + + def cb_continuous_gait(self, request, response): + """Enable/disable continuous gait mode.""" + self.queue.put({"type": "continuous_gait", "enable": request.enable}) + response.success = True + response.message = f"Continuous gait {'enabled' if request.enable else 'disabled'}" + self.get_logger().info(f"Service: /go2/continuous_gait enable={request.enable}") + return response + + def cb_handstand(self, request, response): + """Enable/disable handstand mode.""" + self.queue.put({"type": "handstand", "enable": request.enable}) + response.success = True + response.message = f"Handstand {'enabled' if request.enable else 'disabled'}" + self.get_logger().info(f"Service: /go2/handstand enable={request.enable}") + return response + + def cb_walk_upright(self, request, response): + """Enable/disable upright walking mode.""" + self.queue.put({"type": "walk_upright", "enable": request.enable}) + response.success = True + response.message = f"Walk upright {'enabled' if request.enable else 'disabled'}" + self.get_logger().info(f"Service: /go2/walk_upright enable={request.enable}") + return response + + def cb_free_avoid(self, request, response): + """Enable/disable obstacle avoidance.""" + self.queue.put({"type": "free_avoid", "enable": request.enable}) + response.success = True + response.message = f"Obstacle avoidance {'enabled' if request.enable else 'disabled'}" + self.get_logger().info(f"Service: /go2/free_avoid enable={request.enable}") + return response + + def cb_free_bound(self, request, response): + """Enable/disable bounding gait.""" + self.queue.put({"type": "free_bound", "enable": request.enable}) + response.success = True + response.message = f"Bounding gait {'enabled' if request.enable else 'disabled'}" + self.get_logger().info(f"Service: /go2/free_bound enable={request.enable}") + return response + + def cb_cross_step(self, request, response): + """Enable/disable cross-step walking.""" + self.queue.put({"type": "cross_step", "enable": request.enable}) + response.success = True + response.message = f"Cross-step {'enabled' if request.enable else 'disabled'}" + self.get_logger().info(f"Service: /go2/cross_step enable={request.enable}") + return response + + def cb_free_jump(self, request, response): + """Enable/disable jumping capability.""" + self.queue.put({"type": "free_jump", "enable": request.enable}) + response.success = True + response.message = f"Jumping {'enabled' if request.enable else 'disabled'}" + self.get_logger().info(f"Service: /go2/free_jump enable={request.enable}") + return response + + # ================================================================ + # GESTURE / TRICK SERVICE CALLBACKS + # ================================================================ + + def cb_hello(self, request, response): + """Say hello (wave gesture).""" + self.queue.put({"type": "hello"}) + response.success = True + response.message = "Hello gesture command sent" + self.get_logger().info("Service: /go2/hello") + return response + + def cb_stretch(self, request, response): + """Perform stretch.""" + self.queue.put({"type": "stretch"}) + response.success = True + response.message = "Stretch command sent" + self.get_logger().info("Service: /go2/stretch") + return response + + def cb_wallow(self, request, response): + """Roll over.""" + self.queue.put({"type": "wallow"}) + response.success = True + response.message = "Wallow (roll) command sent" + self.get_logger().info("Service: /go2/wallow") + return response + + def cb_pose_on(self, request, response): + """Strike a pose.""" + self.queue.put({"type": "pose", "enable": True}) + response.success = True + response.message = "Pose on command sent" + self.get_logger().info("Service: /go2/pose_on") + return response + + def cb_pose_off(self, request, response): + """Exit pose.""" + self.queue.put({"type": "pose", "enable": False}) + response.success = True + response.message = "Pose off command sent" + self.get_logger().info("Service: /go2/pose_off") + return response + + def cb_scrape(self, request, response): + """New Year greeting gesture.""" + self.queue.put({"type": "scrape"}) + response.success = True + response.message = "Scrape (New Year greeting) command sent" + self.get_logger().info("Service: /go2/scrape") + return response + + def cb_front_flip(self, request, response): + """Perform front flip.""" + self.queue.put({"type": "front_flip"}) + response.success = True + response.message = "Front flip command sent" + self.get_logger().info("Service: /go2/front_flip") + return response + + def cb_front_jump(self, request, response): + """Jump forward.""" + self.queue.put({"type": "front_jump"}) + response.success = True + response.message = "Front jump command sent" + self.get_logger().info("Service: /go2/front_jump") + return response + + def cb_front_pounce(self, request, response): + """Pounce forward.""" + self.queue.put({"type": "front_pounce"}) + response.success = True + response.message = "Front pounce command sent" + self.get_logger().info("Service: /go2/front_pounce") + return response + + def cb_left_flip(self, request, response): + """Perform left flip.""" + self.queue.put({"type": "left_flip"}) + response.success = True + response.message = "Left flip command sent" + self.get_logger().info("Service: /go2/left_flip") + return response + + def cb_back_flip(self, request, response): + """Perform back flip.""" + self.queue.put({"type": "back_flip"}) + response.success = True + response.message = "Back flip command sent" + self.get_logger().info("Service: /go2/back_flip") + return response + + def cb_dance1(self, request, response): + """Perform dance routine 1.""" + self.queue.put({"type": "dance1"}) + response.success = True + response.message = "Dance 1 command sent" + self.get_logger().info("Service: /go2/dance1") + return response + + def cb_dance2(self, request, response): + """Perform dance routine 2.""" + self.queue.put({"type": "dance2"}) + response.success = True + response.message = "Dance 2 command sent" + self.get_logger().info("Service: /go2/dance2") + return response + + +def main(args=None): + """ + Main entry point. + Starts the backend process and ROS 2 service node. + """ + cmd_queue = Queue() + + backend = Process(target=backend_loop, args=(cmd_queue,), daemon=True) + backend.start() + + rclpy.init(args=args) + node = Go2ServiceNode(cmd_queue) + + try: + rclpy.spin(node) + except KeyboardInterrupt: + node.get_logger().info("Shutting down...") + finally: + cmd_queue.put(None) + node.destroy_node() + rclpy.shutdown() + backend.join(timeout=2.0) + if backend.is_alive(): + backend.terminate() + + +if __name__ == "__main__": + main() diff --git a/examples/4_unitree_go2/real_robot/go2_ws/src/go2_control/package.xml b/examples/4_unitree_go2/real_robot/go2_ws/src/go2_control/package.xml new file mode 100644 index 0000000..ef7e810 --- /dev/null +++ b/examples/4_unitree_go2/real_robot/go2_ws/src/go2_control/package.xml @@ -0,0 +1,20 @@ + + + + go2_control + 1.0.0 + ROS 2 control node for Unitree Go2 quadruped robot + Bharat Jain + MIT + + ament_python + + rclpy + std_msgs + geometry_msgs + go2_interfaces + + + ament_python + + diff --git a/examples/4_unitree_go2/real_robot/go2_ws/src/go2_control/resource/go2_control b/examples/4_unitree_go2/real_robot/go2_ws/src/go2_control/resource/go2_control new file mode 100644 index 0000000..e69de29 diff --git a/examples/4_unitree_go2/real_robot/go2_ws/src/go2_control/setup.cfg b/examples/4_unitree_go2/real_robot/go2_ws/src/go2_control/setup.cfg new file mode 100644 index 0000000..1c12bcb --- /dev/null +++ b/examples/4_unitree_go2/real_robot/go2_ws/src/go2_control/setup.cfg @@ -0,0 +1,5 @@ +[develop] +script_dir=$base/lib/go2_control + +[install] +install_scripts=$base/lib/go2_control diff --git a/examples/4_unitree_go2/real_robot/go2_ws/src/go2_control/setup.py b/examples/4_unitree_go2/real_robot/go2_ws/src/go2_control/setup.py new file mode 100644 index 0000000..911eafb --- /dev/null +++ b/examples/4_unitree_go2/real_robot/go2_ws/src/go2_control/setup.py @@ -0,0 +1,25 @@ +from setuptools import find_packages, setup + +package_name = "go2_control" + +setup( + name=package_name, + version="1.0.0", + packages=find_packages(exclude=["test"]), + data_files=[ + ("share/ament_index/resource_index/packages", ["resource/" + package_name]), + ("share/" + package_name, ["package.xml"]), + ], + install_requires=["setuptools"], + zip_safe=True, + maintainer="Bharat Jain", + maintainer_email="bharat.jain@plaksha.edu.in", + description="ROS 2 control node for Unitree Go2 quadruped robot", + license="MIT", + tests_require=["pytest"], + entry_points={ + "console_scripts": [ + "go2_services = go2_control.go2_service_node:main", + ], + }, +) diff --git a/examples/4_unitree_go2/real_robot/go2_ws/src/go2_interfaces/CMakeLists.txt b/examples/4_unitree_go2/real_robot/go2_ws/src/go2_interfaces/CMakeLists.txt new file mode 100644 index 0000000..39c6678 --- /dev/null +++ b/examples/4_unitree_go2/real_robot/go2_ws/src/go2_interfaces/CMakeLists.txt @@ -0,0 +1,19 @@ +cmake_minimum_required(VERSION 3.8) +project(go2_interfaces) + +if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") + add_compile_options(-Wall -Wextra -Wpedantic) +endif() + +find_package(ament_cmake REQUIRED) +find_package(rosidl_default_generators REQUIRED) + +rosidl_generate_interfaces(${PROJECT_NAME} + "srv/Trigger.srv" + "srv/SetBool.srv" + "srv/Move.srv" + "srv/SetFloat.srv" + "srv/SetInt.srv" +) + +ament_package() diff --git a/examples/4_unitree_go2/real_robot/go2_ws/src/go2_interfaces/package.xml b/examples/4_unitree_go2/real_robot/go2_ws/src/go2_interfaces/package.xml new file mode 100644 index 0000000..bfcd5d1 --- /dev/null +++ b/examples/4_unitree_go2/real_robot/go2_ws/src/go2_interfaces/package.xml @@ -0,0 +1,20 @@ + + + + go2_interfaces + 1.0.0 + Custom service definitions for Unitree Go2 robot control + Bharat Jain + MIT + + ament_cmake + rosidl_default_generators + + rosidl_default_runtime + + rosidl_interface_packages + + + ament_cmake + + diff --git a/examples/4_unitree_go2/real_robot/go2_ws/src/go2_interfaces/srv/Move.srv b/examples/4_unitree_go2/real_robot/go2_ws/src/go2_interfaces/srv/Move.srv new file mode 100644 index 0000000..d50bbff --- /dev/null +++ b/examples/4_unitree_go2/real_robot/go2_ws/src/go2_interfaces/srv/Move.srv @@ -0,0 +1,35 @@ +# Move.srv +# Movement service for the Go2 robot with distance-based control +# +# The robot will move at the specified velocities for the calculated +# duration needed to cover the specified distance. +# +# COORDINATE FRAME: +# +X = Forward (robot's heading direction) +# +Y = Left (robot's left side) +# Positive vyaw = Counter-clockwise rotation (turn left) +# +# VELOCITY LIMITS (from SDK documentation): +# vx: -2.5 to 3.8 m/s (forward/backward) +# vy: -1.0 to 1.0 m/s (left/right strafe) +# vyaw: -4.0 to 4.0 rad/s (rotation) +# +# USAGE EXAMPLES: +# Move forward 1 meter: vx=0.3, vy=0.0, vyaw=0.0, distance=1.0 +# Move backward 0.5m: vx=-0.3, vy=0.0, vyaw=0.0, distance=0.5 +# Strafe left 1 meter: vx=0.0, vy=0.2, vyaw=0.0, distance=1.0 +# Strafe right 0.5m: vx=0.0, vy=-0.2, vyaw=0.0, distance=0.5 +# Turn left 90 degrees: vx=0.0, vy=0.0, vyaw=0.5, distance=1.57 (radians) +# Turn right 180 degrees: vx=0.0, vy=0.0, vyaw=-0.5, distance=3.14 (radians) + +# REQUEST +float64 vx # Forward velocity in m/s (positive=forward, negative=backward) +float64 vy # Lateral velocity in m/s (positive=left, negative=right) +float64 vyaw # Angular velocity in rad/s (positive=CCW/left, negative=CW/right) +float64 distance # Distance to travel in meters, OR angle in radians for pure rotation +--- +# RESPONSE +bool success # True if command was accepted and executed +string message # Human-readable status message with execution details +float64 estimated_time # Estimated time to complete the movement in seconds +int32 iterations # Number of control iterations that will be executed diff --git a/examples/4_unitree_go2/real_robot/go2_ws/src/go2_interfaces/srv/SetBool.srv b/examples/4_unitree_go2/real_robot/go2_ws/src/go2_interfaces/srv/SetBool.srv new file mode 100644 index 0000000..d8f04c4 --- /dev/null +++ b/examples/4_unitree_go2/real_robot/go2_ws/src/go2_interfaces/srv/SetBool.srv @@ -0,0 +1,12 @@ +# SetBool.srv +# Service for enabling/disabling robot features +# +# Used by: handstand, walk_upright, free_avoid, free_bound, +# cross_step, free_jump, switch_joystick, continuous_gait + +# REQUEST +bool enable # True to enable, False to disable +--- +# RESPONSE +bool success # True if command was accepted +string message # Human-readable status message diff --git a/examples/4_unitree_go2/real_robot/go2_ws/src/go2_interfaces/srv/SetFloat.srv b/examples/4_unitree_go2/real_robot/go2_ws/src/go2_interfaces/srv/SetFloat.srv new file mode 100644 index 0000000..3ef6f6d --- /dev/null +++ b/examples/4_unitree_go2/real_robot/go2_ws/src/go2_interfaces/srv/SetFloat.srv @@ -0,0 +1,11 @@ +# SetFloat.srv +# Service for setting float parameters +# +# Used by: body_height, foot_raise_height + +# REQUEST +float64 value # The value to set +--- +# RESPONSE +bool success # True if command was accepted +string message # Human-readable status message diff --git a/examples/4_unitree_go2/real_robot/go2_ws/src/go2_interfaces/srv/SetInt.srv b/examples/4_unitree_go2/real_robot/go2_ws/src/go2_interfaces/srv/SetInt.srv new file mode 100644 index 0000000..f95165f --- /dev/null +++ b/examples/4_unitree_go2/real_robot/go2_ws/src/go2_interfaces/srv/SetInt.srv @@ -0,0 +1,11 @@ +# SetInt.srv +# Service for setting integer parameters +# +# Used by: speed_level, switch_gait + +# REQUEST +int32 value # The value to set +--- +# RESPONSE +bool success # True if command was accepted +string message # Human-readable status message diff --git a/examples/4_unitree_go2/real_robot/go2_ws/src/go2_interfaces/srv/Trigger.srv b/examples/4_unitree_go2/real_robot/go2_ws/src/go2_interfaces/srv/Trigger.srv new file mode 100644 index 0000000..5585c0e --- /dev/null +++ b/examples/4_unitree_go2/real_robot/go2_ws/src/go2_interfaces/srv/Trigger.srv @@ -0,0 +1,13 @@ +# Trigger.srv +# Simple trigger service for actions that don't require parameters +# +# Used by: stand_up, stand_down, recovery, balance, stop, damp, +# sit, rise_sit, hello, stretch, wallow, pose_on, pose_off, +# scrape, front_flip, front_jump, front_pounce, +# dance1, dance2, left_flip, back_flip + +# REQUEST - empty, no parameters needed +--- +# RESPONSE +bool success # True if command was accepted +string message # Human-readable status message diff --git a/examples/4_unitree_go2/real_robot/scripts/camera_bridge.py b/examples/4_unitree_go2/real_robot/scripts/camera_bridge.py new file mode 100644 index 0000000..5f36b1d --- /dev/null +++ b/examples/4_unitree_go2/real_robot/scripts/camera_bridge.py @@ -0,0 +1,69 @@ +import cv2 +import rclpy +from cv_bridge import CvBridge +from rclpy.node import Node +from rclpy.qos import HistoryPolicy, QoSProfile, ReliabilityPolicy +from sensor_msgs.msg import Image + + +class ImagePublisher(Node): + def __init__(self): + super().__init__("image_publisher") + + qos_profile = QoSProfile( + reliability=ReliabilityPolicy.RELIABLE, history=HistoryPolicy.KEEP_LAST, depth=10 + ) + + # create publisher + self.publisher_ = self.create_publisher(Image, "camera/rgb/image_raw", qos_profile) + self.bridge = CvBridge() + + # GStreamer pipeline + gstreamer_str = ( + "udpsrc address=230.1.1.1 port=1720 multicast-iface= ! " + "application/x-rtp, media=video, encoding-name=H264 ! " + "rtph264depay ! h264parse ! avdec_h264 ! videoconvert ! " + "video/x-raw,width=1280,height=720,format=BGR ! appsink drop=1" + ) + + self.cap = cv2.VideoCapture(gstreamer_str, cv2.CAP_GSTREAMER) + + # create timer for periodic frame reading + self.timer = self.create_timer(0.016, self.timer_callback) # ~60fps + + def timer_callback(self): + if self.cap.isOpened(): + ret, frame = self.cap.read() + if ret: + # resize frame to 320x240 + resized = cv2.resize(frame, (320, 240), interpolation=cv2.INTER_AREA) + + # OpenCV frame โ†’ ROS Image message conversion + img_msg = self.bridge.cv2_to_imgmsg(resized, encoding="bgr8") + img_msg.header.stamp = self.get_clock().now().to_msg() + img_msg.header.frame_id = "camera_link" + self.publisher_.publish(img_msg) + + else: + self.get_logger().warn("Frame capture failed") + + def destroy_node(self): + self.cap.release() + cv2.destroyAllWindows() + super().destroy_node() + + +def main(args=None): + rclpy.init(args=args) + node = ImagePublisher() + try: + rclpy.spin(node) + except KeyboardInterrupt: + pass + finally: + node.destroy_node() + rclpy.shutdown() + + +if __name__ == "__main__": + main() diff --git a/examples/5_docker_turtlesim/README.md b/examples/5_docker_turtlesim/README.md new file mode 100644 index 0000000..2160e0a --- /dev/null +++ b/examples/5_docker_turtlesim/README.md @@ -0,0 +1,330 @@ +# Example - TurtleSim in Docker + +For users who want to test the MCP server without needing to install ROS, this is an example that provides a dockerized ROS2 container preinstalled with the simplest possible 'robot' in the ROS ecosystem - TurtleSim. + +Turtlesim is a lightweight simulator for learning ROS / ROS 2. It illustrates what ROS does at the most basic level to give you an idea of what you will do with a real robot or a robot simulation later on. + +## Prerequisites + +โœ… **Cross-Platform Support:** This tutorial works on Linux, macOS, and Windows with proper X11/display forwarding setup. Each platform has specific requirements detailed below. + +Before starting this tutorial, make sure you have the following installed: + +### All Platforms +- **Docker**: [Install Docker](https://docs.docker.com/get-docker/) +- **Docker Compose**: Usually comes with Docker Desktop, or install separately + +### Platform-Specific Requirements + +#### macOS +- **XQuartz**: Install from [XQuartz website](https://www.xquartz.org/) +- **Important**: XQuartz setup can be complex - see [macOS Setup](#macos-setup) section below for detailed instructions + +#### Linux +- **X11 forwarding**: `sudo apt-get install x11-apps` +- Usually works out of the box with minimal setup + +#### Windows +- **X Server**: Install [X410](https://x410.dev/), [VcXsrv](https://sourceforge.net/projects/vcxsrv/), or another X Server from Microsoft Store +- **WSL recommended**: Works best with Windows Subsystem for Linux + +
+PowerShell and WSL (Windows) + +- Install Docker from [installer](https://docs.docker.com/desktop/setup/install/windows-install) or Microsoft Store +- Open Docker Desktop > Settings > Resources > WSL Integration +- Enable your distro (e.g., Ubuntu 22.04) +- Verify installation: in PowerShell `docker --version` and in WSL `docker --version` +
+ +## Quick Start + +### 1. Build the Container + +Navigate to the turtlesim example directory and build the Docker container: + +```bash +cd examples/5_docker_turtlesim +docker compose build --no-cache turtlesim +``` + +### 2. Launch Turtlesim + +#### Automatic Setup (Recommended) + +The easiest way to launch turtlesim with proper X11 setup: + +```bash +./scripts/launch.sh +``` + +This script automatically detects your OS and handles all platform-specific X11 configuration. It will: +- **macOS**: Start XQuartz, detect display, configure IP-based forwarding +- **Linux**: Set up X11 permissions with `xhost +local:docker` +- **Windows**: Configure X server connection via `host.docker.internal` + +#### Manual Setup (Advanced) + +If you prefer manual control or the automatic script doesn't work: + +**macOS:** +```bash +./scripts/launch_macos.sh +``` + +**Linux (or Windows WSL):** +```bash +./scripts/launch_linux.sh +``` + +**Windows:** +```bash +./scripts/launch_windows.sh +``` + +The container will automatically start both turtlesim and rosbridge websocket server. You should see: + +- A turtlesim window appear with a turtle +- ROS Bridge WebSocket server running on `ws://localhost:9090` +- Turtle teleop ready for keyboard input + +### 3. Access the Container (Optional) + +If you need to access the container for debugging or additional commands: + +Launch the container in the background: +```bash +docker compose up -d +``` + +And attach to the container: +```bash +docker exec -it ros2-turtlesim bash +``` + +Once inside the container, you can manually launch turtle teleop to control the turtle: + +```bash +source /opt/ros/${ROS_DISTRO}/setup.bash +ros2 run turtlesim turtle_teleop_key +``` + +This will allow you to use arrow keys or WASD to manually move the turtle around the turtlesim window. + +## Integration with MCP Server + +Once turtlesim and rosbridge are running, you can connect the MCP server to control the turtle programmatically. +Follow the [installation guide](../../docs/install/installation.md) for full setup instructions if you haven't already set up the MCP server. + +Since it is running on the same machine, you can tell the LLM to connect to the robot on localhost. + +## Platform-Specific Setup + +
+macOS Setup + +macOS requires special X11 forwarding setup. Follow these steps carefully: + +### Step 1: Install XQuartz +Download and install from [XQuartz website](https://www.xquartz.org/) + +### Step 2: Configure XQuartz +1. **Start XQuartz**: `open -a XQuartz` +2. **Wait for it to fully load** (you'll see an xterm window) + +### Step 3: Detect Your Setup +```bash +# Check if XQuartz is running and which display it's using +ps aux | grep -i xquartz + +# You should see something like: +# /opt/X11/bin/Xquartz :1 -listen tcp ... +# The `:1` or `:0` is your display number +``` + +### Step 4: Get Your Machine IP +```bash +# Get your machine's IP address +ifconfig en0 | grep inet | awk '$1=="inet" {print $2}' +# Example output: 10.1.56.72 +``` + +### Step 5: Set Up Environment +```bash +# Set DISPLAY for your Mac (use the display number from Step 3) +export DISPLAY=:1 # or :0 depending on what you found + +# Allow X11 connections +xhost + + +# Set DISPLAY for Docker (use your IP from Step 4 + display number) +export DOCKER_DISPLAY= # Replace with your actual IP +``` +
+ +## Troubleshooting + +### macOS Display Issues + +
+Problem: qt.qpa.xcb: could not connect to display + +**Solutions**: + +1. **Check XQuartz is running**: + ```bash + ps aux | grep X11 + ``` + +2. **Verify display number**: + ```bash + # Look for :0 or :1 in the Xquartz process + ps aux | grep Xquartz | grep -o ":[0-9]" + ``` + +3. **Check your IP address**: + ```bash + ifconfig en0 | grep inet + ``` + +4. **Set correct DOCKER_DISPLAY**: + ```bash + export DOCKER_DISPLAY="YOUR_IP:DISPLAY_NUMBER" + # Example: export DOCKER_DISPLAY="10.1.56.72:1" + ``` + +5. **Allow X11 connections**: + ```bash + export DISPLAY=:1 # Use your display number + xhost + + ``` +
+ +
+Problem: xhost: unable to open display ":0" + +**Solution**: XQuartz might be using `:1` instead of `:0`: + +```bash +export DISPLAY=:1 +xhost + +``` +
+ +### Linux Display Issues + +
+Display issues on Linux + +If you encounter display issues on Linux, run: + +```bash +xhost +local:docker +``` +
+ +### Windows Display Issues + +
+Display issues on Windows + +For Windows users, make sure you install an X Server (X410) and set the DISPLAY: + +```bash +$env:DOCKER_DISPLAY="host.docker.internal:0.0" +``` +
+ +### General Issues + +
+Problem: Container starts but no window appears + +**Solutions**: +1. Check if the window is hidden behind other windows +2. Look in Mission Control (macOS) or Alt+Tab (Windows/Linux) +3. Verify your DOCKER_DISPLAY is set correctly for your platform +
+ +
+Problem: libGL error: No matching fbConfigs or visuals found + +**Solution**: This is just a warning and doesn't prevent the GUI from working. The turtlesim window should still appear. +
+ +## Manual Launch (Alternative) + +If the automatic launch isn't working or you prefer to launch turtlesim manually, you can run these commands inside the container: + +```bash +# Access the container +docker exec -it ros2-turtlesim bash + +# Source ROS2 environment +source /opt/ros/${ROS_DISTRO}/setup.bash + +# Start turtlesim in one terminal +ros2 run turtlesim turtlesim_node + +# In another terminal, start the teleop node +ros2 run turtlesim turtle_teleop_key +``` + +## Testing Turtlesim + +If you need to verify that turtlesim is working correctly: + +### ROS2 Topic Inspection + +In a separate terminal, you can inspect the ROS2 topics: + +```bash +# Access the container +docker exec -it ros2-turtlesim bash + +# Source ROS2 environment +source /opt/ros/${ROS_DISTRO}/setup.bash + +# List all topics +ros2 topic list + +# Echo turtle position +ros2 topic echo /turtle1/pose + +# Echo turtle velocity commands +ros2 topic echo /turtle1/cmd_vel +``` + +### ROS Bridge WebSocket Server + +The rosbridge websocket server is automatically started and available at `ws://localhost:9090`. You can test the connection using a WebSocket client or the MCP server. + +To verify rosbridge is running, you can check the container logs: + +```bash +docker logs ros2-turtlesim +``` + +## Cleanup + +To stop and remove the container: + +```bash +docker-compose down +``` + +To remove the built image: + +```bash +docker rmi ros-mcp-server_turtlesim +``` + +## Next Steps + +Now that you have turtlesim running, you can: + +1. **Try more complex commands** like drawing shapes or following paths +2. **Install ROS Locally** to add more nodes and services +3. **Explore other examples in this repository** + +This example provides a foundation for understanding how the MCP server can interact with ROS2 systems, from simple simulators like turtlesim to complex robotic platforms. \ No newline at end of file diff --git a/examples/5_docker_turtlesim/docker-compose.yml b/examples/5_docker_turtlesim/docker-compose.yml new file mode 100644 index 0000000..44ee388 --- /dev/null +++ b/examples/5_docker_turtlesim/docker-compose.yml @@ -0,0 +1,20 @@ +services: + turtlesim: + build: + context: ./docker + dockerfile: Dockerfile.turtlesim + container_name: ros2-turtlesim + environment: + - DISPLAY=${DISPLAY} + - ROS_DISTRO=humble + - QT_X11_NO_MITSHM=1 + volumes: + # Mount launch file into the container + - ./launch_turtlesim.launch.py:/ros2_ws/launch/launch_turtlesim.launch.py:ro + # Linux X11 socket (only works on Linux) + - /tmp/.X11-unix:/tmp/.X11-unix:rw + ports: + - "9090:9090" + stdin_open: true + tty: true + command: ["ros2", "launch", "/ros2_ws/launch/launch_turtlesim.launch.py"] diff --git a/examples/5_docker_turtlesim/docker/Dockerfile.turtlesim b/examples/5_docker_turtlesim/docker/Dockerfile.turtlesim new file mode 100644 index 0000000..a27f625 --- /dev/null +++ b/examples/5_docker_turtlesim/docker/Dockerfile.turtlesim @@ -0,0 +1,20 @@ +# ROS2 Humble with Turtlesim +FROM osrf/ros:humble-desktop + +ENV ROS_DISTRO=humble +SHELL ["/bin/bash", "-c"] + +# Install turtlesim and rosbridge packages +RUN apt-get update && apt-get install -y \ + ros-humble-turtlesim \ + ros-humble-rosbridge-suite \ + && rm -rf /var/lib/apt/lists/* + +# Auto-source ROS environment in container shell +RUN echo "source /opt/ros/humble/setup.bash" >> ~/.bashrc + +# Set working directory +WORKDIR /ros2_ws + +# Default command to keep container running +CMD ["/bin/bash"] diff --git a/examples/5_docker_turtlesim/launch_turtlesim.launch.py b/examples/5_docker_turtlesim/launch_turtlesim.launch.py new file mode 100644 index 0000000..f1204eb --- /dev/null +++ b/examples/5_docker_turtlesim/launch_turtlesim.launch.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 + +""" +ROS2 Launch file for Turtlesim with Rosbridge WebSocket Server +Launches both turtlesim node and rosbridge server for MCP integration. +""" + +from launch.actions import DeclareLaunchArgument, LogInfo +from launch.substitutions import LaunchConfiguration +from launch_ros.actions import Node + +from launch import LaunchDescription + + +def generate_launch_description(): + """Generate the launch description for turtlesim with rosbridge.""" + + # Declare launch arguments + port_arg = DeclareLaunchArgument( + "port", default_value="9090", description="Port for rosbridge websocket server" + ) + + address_arg = DeclareLaunchArgument( + "address", + default_value="", + description="Address for rosbridge websocket server (empty for all interfaces)", + ) + + log_level_arg = DeclareLaunchArgument( + "log_level", default_value="info", description="Log level for nodes" + ) + + # Turtlesim node + turtlesim_node = Node( + package="turtlesim", + executable="turtlesim_node", + name="turtlesim", + output="screen", + arguments=["--ros-args", "--log-level", LaunchConfiguration("log_level")], + ) + + # Rosbridge websocket server node + rosbridge_node = Node( + package="rosbridge_server", + executable="rosbridge_websocket", + name="rosbridge_websocket", + output="screen", + parameters=[ + { + "port": LaunchConfiguration("port"), + "address": LaunchConfiguration("address"), + "use_compression": False, + "max_message_size": 10000000, + "send_action_goals_in_new_thread": True, + "call_services_in_new_thread": True, + "default_call_service_timeout": 5.0, + } + ], + arguments=["--ros-args", "--log-level", LaunchConfiguration("log_level")], + ) + + # ROS API node (needed for some rosbridge operations) + rosapi_node = Node( + package="rosapi", + executable="rosapi_node", + name="rosapi", + output="screen", + arguments=["--ros-args", "--log-level", LaunchConfiguration("log_level")], + ) + + # Log info + log_info = LogInfo( + msg=[ + "Starting Turtlesim with Rosbridge WebSocket Server:", + " - Port: ", + LaunchConfiguration("port"), + " - Log level: ", + LaunchConfiguration("log_level"), + ] + ) + + return LaunchDescription( + [ + port_arg, + address_arg, + log_level_arg, + log_info, + rosbridge_node, + rosapi_node, + turtlesim_node, + ] + ) diff --git a/examples/5_docker_turtlesim/scripts/launch.sh b/examples/5_docker_turtlesim/scripts/launch.sh new file mode 100755 index 0000000..deebd23 --- /dev/null +++ b/examples/5_docker_turtlesim/scripts/launch.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# Unified launcher for turtlesim - auto-detects OS and runs appropriate script + +set -e + +# Detect operating system +OS_TYPE=$(uname -s) + +case "$OS_TYPE" in + Darwin*) + echo "Detected macOS - launching with XQuartz support..." + ./scripts/launch_macos.sh + ;; + Linux*) + echo "Detected Linux - launching with X11 support..." + ./scripts/launch_linux.sh + ;; + MINGW*|MSYS*|CYGWIN*) + echo "Detected Windows - launching with X server support..." + ./scripts/launch_windows.sh + ;; + *) + echo "Unsupported OS: $OS_TYPE" + echo " Please run the appropriate script manually:" + echo " - macOS: ./scripts/launch_macos.sh" + echo " - Linux: ./scripts/launch_linux.sh" + echo " - Windows: ./scripts/launch_windows.sh" + exit 1 + ;; +esac diff --git a/examples/5_docker_turtlesim/scripts/launch_linux.sh b/examples/5_docker_turtlesim/scripts/launch_linux.sh new file mode 100755 index 0000000..33cb46d --- /dev/null +++ b/examples/5_docker_turtlesim/scripts/launch_linux.sh @@ -0,0 +1,42 @@ +#!/bin/bash +# Linux startup script for turtlesim GUI + +set -e + +echo "Starting Turtlesim on Linux with GUI" +echo "========================================" + +# Step 1: Check if X11 is available +if [[ -z "$DISPLAY" ]]; then + echo "ERROR: DISPLAY variable not set" + echo "X11 forwarding is required. Are you running in a GUI environment?" + exit 1 +fi + +echo "Using DISPLAY: $DISPLAY" + +# Step 2: Allow Docker to access X11 +if command -v xhost >/dev/null 2>&1; then + echo "Allowing Docker X11 access..." + xhost +local:docker >/dev/null 2>&1 || { + echo "WARNING: xhost command failed, trying without sudo..." + xhost + >/dev/null 2>&1 || echo "WARNING: Could not run xhost, X11 forwarding may not work" + } +else + echo "WARNING: xhost not found. Install with: sudo apt-get install x11-xserver-utils" + exit 1 +fi + +# Step 3: Export DISPLAY for Docker +echo "Docker will use DISPLAY=$DISPLAY" + +# Step 4: Start the container +echo "" +echo "Starting Turtlesim container..." +echo "If successful, you should see a window with a turtle!" +echo "" + +docker compose up turtlesim + +echo "" +echo "Done! The turtle window should be visible on your screen." diff --git a/examples/5_docker_turtlesim/scripts/launch_macos.sh b/examples/5_docker_turtlesim/scripts/launch_macos.sh new file mode 100755 index 0000000..63138b9 --- /dev/null +++ b/examples/5_docker_turtlesim/scripts/launch_macos.sh @@ -0,0 +1,92 @@ +#!/bin/bash +# Simple macOS startup script for turtlesim GUI +# This script handles all the X11 forwarding complexity automatically + +set -e + +echo "Starting Turtlesim on macOS with GUI" +echo "========================================" + +# Function to detect display number +detect_display() { + if pgrep -f "Xquartz :1" > /dev/null; then + echo "1" + elif pgrep -f "Xquartz :0" > /dev/null; then + echo "0" + else + echo "0" # Default fallback + fi +} + +# Function to get machine IP +get_machine_ip() { + # Try en0 first (most common) + local ip=$(ifconfig en0 2>/dev/null | grep 'inet ' | awk '{print $2}' | head -n1) + + # If en0 doesn't work, try other interfaces + if [[ -z "$ip" ]]; then + ip=$(ifconfig 2>/dev/null | grep 'inet ' | grep -v '127.0.0.1' | awk '{print $2}' | head -n1) + fi + + echo "$ip" +} + +# Step 1: Start XQuartz if not running +if ! pgrep -x "X11.bin" > /dev/null; then + echo "Starting XQuartz..." + open -a XQuartz + + # Wait for XQuartz to start (up to 10 seconds) + for i in {1..10}; do + if pgrep -x "X11.bin" > /dev/null; then + echo "XQuartz started" + break + fi + echo " Waiting for XQuartz... ($i/10)" + sleep 1 + done + + if ! pgrep -x "X11.bin" > /dev/null; then + echo "ERROR: XQuartz failed to start. Please start it manually." + exit 1 + fi +else + echo "XQuartz already running" +fi + +# Step 2: Detect display number and machine IP +DISPLAY_NUM=$(detect_display) +MACHINE_IP=$(get_machine_ip) + +if [[ -z "$MACHINE_IP" ]]; then + echo "ERROR: Could not detect machine IP address" + echo "Please run: ifconfig en0 | grep inet" + exit 1 +fi + +echo "Display detected: :$DISPLAY_NUM" +echo "Machine IP: $MACHINE_IP" + +# Step 3: Set up X11 permissions +export DISPLAY=:$DISPLAY_NUM +if command -v xhost >/dev/null 2>&1; then + echo "Allowing X11 connections..." + xhost + >/dev/null 2>&1 || echo "WARNING: xhost command failed, but continuing..." +else + echo "WARNING: xhost not found - X11 forwarding may not work" +fi + +# Step 4: Export DISPLAY for Docker +export DISPLAY="$MACHINE_IP:$DISPLAY_NUM" +echo "Docker will use DISPLAY=$DISPLAY" + +# Step 5: Start the container +echo "" +echo "Starting Turtlesim container..." +echo " If successful, you should see a window with a turtle!" +echo "" + +docker compose up turtlesim + +echo "" +echo "Done! The turtle window should be visible on your screen." \ No newline at end of file diff --git a/examples/5_docker_turtlesim/scripts/launch_windows.sh b/examples/5_docker_turtlesim/scripts/launch_windows.sh new file mode 100755 index 0000000..9fcd3e8 --- /dev/null +++ b/examples/5_docker_turtlesim/scripts/launch_windows.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# Windows (WSL/Git Bash) startup script for turtlesim GUI +# Requires an X server like VcXsrv, X410, or Xming running on Windows + +set -e + +echo "Starting Turtlesim on Windows with GUI" +echo "==========================================" + +# Step 1: Check if running in WSL or Git Bash +if grep -qi microsoft /proc/version 2>/dev/null; then + PLATFORM="WSL" +elif uname -s | grep -qi "MINGW\|MSYS\|CYGWIN"; then + PLATFORM="Git Bash/MSYS" +else + PLATFORM="Unknown" +fi + +echo "Detected platform: $PLATFORM" + +# Step 2: Check if X server is running on Windows +echo "Checking for X server on Windows..." +echo " Make sure VcXsrv, X410, or another X server is running!" +echo " If not installed, get VcXsrv from: https://sourceforge.net/projects/vcxsrv/" + +# Step 3: Set DISPLAY for Docker +# Docker Desktop on Windows uses host.docker.internal to reach Windows host +export DISPLAY="host.docker.internal:0.0" +echo "Docker will use DISPLAY=$DISPLAY" + +# Step 4: Warn about common issues +cat << 'TIPS' + +Important Notes for Windows: + - Start your X server (VcXsrv/X410) BEFORE running this script + - In VcXsrv: disable "Native opengl" and enable "Disable access control" + - If GUI doesn't appear, check Windows Firewall settings + - Docker Desktop must be running + +TIPS + +# Step 5: Start the container +echo "" +echo "Starting Turtlesim container..." +echo " If successful, you should see a window with a turtle!" +echo "" + +docker compose up turtlesim + +echo "" +echo "Done! The turtle window should appear in your X server." diff --git a/examples/6_chatgpt/README.md b/examples/6_chatgpt/README.md new file mode 100644 index 0000000..5baebcc --- /dev/null +++ b/examples/6_chatgpt/README.md @@ -0,0 +1,489 @@ +# ChatGPT Desktop with ROS-MCP-Server + +> **See also:** The [ChatGPT setup guide](../../docs/install/clients/chatgpt.md) in the main installation docs for a streamlined setup. + +## Prerequisites + +### Prepare host machine (MCP Server) + +* Installation of ChatGPT Desktop. Download [here](https://chatgpt.com/download) or Microsoft Store. +* Installation of WSL (Linux) or a terminal on macOS. + +> ๐Ÿ’ก **macOS Users**: You can run the MCP server and ngrok directly on macOS without WSL. For ROS2, use Docker (see [5_docker_turtlesim](../5_docker_turtlesim/) for a ready-made container) since ROS2 is not natively available on macOS. + +### Prepare target robot (ROS) + +* Installation of Ubuntu, WSL (Windows Subsystem for Linux), or Docker (macOS). +* Installation of ROS or ROS2. Test if ROS is installed by running Turtlesim. If you are not sure, follow this tutorial \[https://wiki.ros.org/ROS/Tutorials] + +# Tutorial + +## Quick Start (For Experienced Users) + +1. **Install dependencies**: `curl -LsSf https://astral.sh/uv/install.sh | sh` and `ngrok config add-authtoken ` +2. **Clone repository**: `cd ~ && git clone https://github.com/robotmcp/ros-mcp-server.git && cd ros-mcp-server` +3. **Start MCP server**: `uv run server.py --transport streamable-http` +4. **Start ngrok tunnel**: `ngrok http --url=your-domain.ngrok-free.app 9000` +5. **Configure ChatGPT**: Add connector with URL `https://your-domain.ngrok-free.app/mcp` +6. **Start ROS**: `ros2 launch rosbridge_server rosbridge_websocket_launch.xml & ros2 run turtlesim turtlesim_node` +7. **Test**: Ask ChatGPT to "List ROS topics" + +## 1.1 Installation on Host Machine + +### Install dependencies + +* Install [`uv`](https://github.com/astral-sh/uv) using one of the following methods: + +
+ Option A: PowerShell (Windows) + + ```bash + winget install --id=astral-sh.uv -e + ``` + or + ```bash + pip install uv + ``` + +
+ +
+ Option B: WSL (Linux) + + ```bash + curl -LsSf https://astral.sh/uv/install.sh | sh + ``` + or (not recommended) + ```bash + pip install uv + ``` + or (not recommended) + ```bash + sudo snap install --classic astral-uv + ``` +
+ +
+ Option C: macOS + + Using Homebrew: + ```bash + brew install uv + ``` + or curl: + ```bash + curl -LsSf https://astral.sh/uv/install.sh | sh + ``` + or pip: + ```bash + pip3 install uv + ``` + > ๐Ÿ’ก **Note**: If installed via pip, you may need to add `~/Library/Python/3.x/bin` to your PATH. +
+ +* Install [`ngrok`](https://dashboard.ngrok.com/get-started/setup/linux) using one of the following methods: + +
+ Option A: PowerShell (Windows) + + Install from Microsoft Store or from [here](https://dashboard.ngrok.com/get-started/setup/windows) +
+ +
+ Option B: WSL (Linux) + + Using Apt: + + ```bash + curl -sSL https://ngrok-agent.s3.amazonaws.com/ngrok.asc \\ + | sudo tee /etc/apt/trusted.gpg.d/ngrok.asc >/dev/null \\ + \&\& echo "deb https://ngrok-agent.s3.amazonaws.com bookworm main" \\ + | sudo tee /etc/apt/sources.list.d/ngrok.list \\ + \&\& sudo apt update \\ + \&\& sudo apt install ngrok + ``` + or snap: + ```bash + sudo snap install ngrok + ``` +
+ +
+ Option C: macOS + + Using Homebrew: + ```bash + brew install ngrok + ``` + or download directly from [ngrok.com/download](https://ngrok.com/download). +
+ +* Login or create your account at [`ngrok`](https://dashboard.ngrok.com/login) +* Navigate to [`Your Authtoken`](https://dashboard.ngrok.com/get-started/your-authtoken) +* Copy your `Authtoken` to clipboard `` +* Activate `ngrok` with the following command, replacing ``: + +```bash +ngrok config add-authtoken +``` +* Obtain a static domain in `ngrok` by navigating to [Domains](https://dashboard.ngrok.com/domains) +* It should look be something like: `abc123-xyz789.ngrok-free.app` +* Save the ROS-MCP url domain, replacing it with yours. + +
+ Option A: PowerShell (Windows) + + ```bash + $env:MCP_DOMAIN=abc123-xyz789.ngrok-free.app + ``` +
+ +
+ Option B: WSL (Linux) + + ```bash + export MCP_DOMAIN=abc123-xyz789.ngrok-free.app + ``` +
+ +
+ Option C: macOS (zsh) + + ```bash + export MCP_DOMAIN=abc123-xyz789.ngrok-free.app + ``` + Add to `~/.zshrc` for persistence. +
+ +* In WSL (Linux) or macOS, consider adding all your `MCP` variables to `.bashrc` or `~/.zshrc` + +```bash +export MCP_HOST=127.0.0.1 +export MCP_PORT=9000 +export MCP_DOMAIN=abc123-xyz789.ngrok-free.app +``` + +### Install ROS-MCP Server + +* On your Host Machine, clone the repository and navigate to the ROS-MCP Server folder. + +```bash +cd ~ +git clone https://github.com/robotmcp/ros-mcp-server.git +cd ros-mcp-server +``` + +> โš ๏ธ **WSL Users**: Clone the repository in your WSL home directory (e.g., `/home/username/`) instead of the Windows filesystem mount (e.g., `/mnt/c/Users/username/`). Using the native Linux filesystem provides better performance and avoids potential permission issues. + + +## 1.2 Run ROS-MCP Server + +* Run the ROS-MCP using one of the following: + +
+ Option A: PowerShell (Windows) + + In PowerShell, set the tranport protocol: + + ```bash + $env:MCP_TRANSPORT="streamable-http" + ``` + + If you installed `uv` used the following: + ```bash + + uv run server.py --transport streamable-http + ``` + + Otherwise you have to install all the dependencies manually and run: + + ```bash + python server.py --transport streamable-http + ``` + +
+ +
+ Option B: WSL (Linux) + + Open WSL + + Run the following: + ```bash + uv run server.py --transport streamable-http + ``` + + or + + ```bash + cd scripts + launch_mcp_server.sh + ``` +
+ +
+ Option C: macOS + + ```bash + uv run server.py --transport streamable-http + ``` + + Or if `uv` was installed via pip and is not in your PATH: + ```bash + python3 -m uv run server.py --transport streamable-http + ``` +
+ +* By default, the server should start at `127.0.0.1:9000`, you can set `MCP_HOST` and `MCP_PORT` variables as needed + + + +## 1.3 Tunnel ROS-MCP Server + +* On your host machine, launch `ngrok` with one of the following: + +
+ Option A: PowerShell (Windows) + + In PowerShell, set the local port to tunnel: + + ```bash + $env:MCP_PORT=9000 + $env:MCP_DOMAIN= + ``` + Run `ngrok` to tunnel your ROS-MCP server. + + ```bash + ngrok http --url=$env:MCP_DOMAIN $env:MCP_PORT + ``` +
+ +
+ Option B: WSL (Linux) + + In WSL, set the local port to tunnel: + ```bash + export MCP_PORT=9000 + export MCP_DOMAIN= + ``` + Run `ngrok` to tunnel your ROS-MCP server. + ```bash + ngrok http --url=${MCP_DOMAIN} ${MCP_PORT} + ``` + Or you can also launch: + ```bash + launch_mcp_tunnel.sh + ``` +
+ +
+ Option C: macOS + + ```bash + export MCP_PORT=9000 + export MCP_DOMAIN= + ngrok http --url=${MCP_DOMAIN} ${MCP_PORT} + ``` +
+ +* Once you launch `ngrok`, verify your public url domain, it should be the same as `MCP_DOMAIN`. + + ```bash + https://your-domain.ngrok-free.app -> http://localhost:9000 + ``` + + +## 1.4 Connect ROS-MCP to ChatGPT + +* Open ChatGPT Desktop +* Navigate to Settings (Bottom Left) +* Open Connectors +* Create and fill the following: + + - Name: *ROS-MCP Server* + - Description: *An MCP Server to connect with ROS/ROS2* + - MCP Server URL: `https://your-domain.ngrok-free.app/mcp` (don't forget to replace with your domain and add the `/mcp`) + - Authentication: *No authentication* + - I trust this application + +* In ChatGPT, start a new Chat, navigate to `+` > `Developer Mode` > `Add sources` > Activate `ROS-MCP Server` + +## 1.5 Run ROS on Target Machine + +In WSL, launch `rosbridge` and `turtlesim` + +```bash +source /opt/ros/jazzy/setup.bash +ros2 launch rosbridge_server rosbridge_websocket_launch.xml & ros2 run turtlesim turtlesim_node +``` + +or + +```bash +launch_ros.sh +``` + +> ๐Ÿ’ก **macOS Users**: ROS2 is not natively available on macOS. Use the Docker-based approach from [5_docker_turtlesim](../5_docker_turtlesim/): +> ```bash +> cd ../5_docker_turtlesim +> docker compose up turtlesim +> ``` +> This starts `rosbridge` on port 9090 and `turtlesim` in a container. The MCP server on your host connects to it automatically. Note that the turtlesim GUI requires X11 forwarding (install [XQuartz](https://www.xquartz.org/)), but `rosbridge` works without it. + +## 1.6 Test Your Setup + +Once everything is configured, test your connection: + +1. **Start a new chat in ChatGPT Desktop** +2. **Activate the ROS-MCP Server** in Developer Mode +3. **Try a simple ROS command** like: + - "List all available ROS topics" + - "Show me the current ROS node information" + - "What robots are available in the specifications?" + +4. **Verify the connection** - you should see ROS-related responses from the MCP server + +## 2. Troubleshooting + +### Common Issues + +**MCP Server not connecting:** +- Verify the server is running: Check if `uv run server.py --transport streamable-http` is active +- Check if ngrok tunnel is working: Visit your ngrok URL in browser +- Ensure the server is accessible at `http://127.0.0.1:9000/mcp` + +**ROS Bridge connection failed:** +- Make sure ROS is running: `ros2 node list` +- Verify rosbridge is active: `ros2 launch rosbridge_server rosbridge_websocket_launch.xml` +- Check if port 9090 is available + +**Ngrok tunnel issues:** +- Verify your authtoken is set: `ngrok config check` +- Check if your domain is active: Visit ngrok dashboard at `http://127.0.0.1:4040/status` +- Ensure port 9000 is not blocked by firewall + +**ChatGPT connector not working:** +- Verify the MCP Server URL includes `/mcp` at the end +- Check if "I trust this application" is checked +- Restart ChatGPT Desktop after adding the connector + +**macOS-specific issues:** +- **No ROS2 natively**: Use Docker โ€” see [5_docker_turtlesim](../5_docker_turtlesim/) or a Linux VM +- **Turtlesim GUI not displaying**: Install [XQuartz](https://www.xquartz.org/) (`brew install --cask xquartz`), log out/in, then run `xhost +localhost` before launching Docker. Alternatively, run headless โ€” rosbridge still works without the GUI. +- **`uv` not found after `pip3 install uv`**: Add `~/Library/Python/3.x/bin` to your PATH, or use `curl -LsSf https://astral.sh/uv/install.sh | sh` instead +- **Port 9000 already in use**: Check with `lsof -i :9000` and kill the conflicting process + +### Debug Commands + +```bash +# Test server locally +curl http://127.0.0.1:9000/mcp + +# Test ngrok tunnel +curl https://your-domain.ngrok-free.app/mcp + +# Test ROS installation (WSL) +wsl -- ros2 node list + +# Test ROS installation (macOS Docker) +docker exec ros2-turtlesim ros2 node list + +# Test rosbridge (WSL) +wsl -- ros2 topic list + +# Test rosbridge (macOS Docker) +docker exec ros2-turtlesim ros2 topic list + +# Check ngrok status +ngrok api tunnels list +``` + +## 3. Usage Examples + +Once connected, you can use natural language to interact with your ROS system: + +- **"Move the turtle forward"** - Controls turtlesim +- **"What topics are currently publishing?"** - Lists active topics +- **"Show me the robot specifications"** - Displays available robot configs +- **"Connect to robot at IP 192.168.1.100"** - Connects to remote robot +- **"Take a picture with the camera"** - Captures camera data +- **"Publish a velocity command"** - Sends movement commands + +## 4. Advanced Configuration + +### Environment Variables + +You can customize the MCP server behavior with these environment variables: + +```bash +# ROS Bridge settings +export ROSBRIDGE_IP="127.0.0.1" # Default: localhost +export ROSBRIDGE_PORT="9090" # Default: 9090 + +# MCP Transport settings +export MCP_TRANSPORT="streamable-http" # For ChatGPT: streamable-http +export MCP_HOST="127.0.0.1" # For HTTP transport +export MCP_PORT="9000" # For HTTP transport + +# Ngrok settings +export MCP_DOMAIN="your-domain.ngrok-free.app" # Your ngrok domain +``` + +## 5. FAQ + +**Q: Do I need ngrok for local testing?** +A: For ChatGPT Desktop, yes. ChatGPT requires a public URL to connect to MCP servers. + +**Q: Can I use a different tunneling service?** +A: Yes, you can use any tunneling service that provides HTTPS URLs, but ngrok is recommended. + +**Q: Is the connection secure?** +A: The connection uses HTTPS through ngrok, but for production use, consider additional security measures. + +**Q: Can I run multiple MCP servers?** +A: Yes, you can configure multiple servers, but each needs its own ngrok tunnel and port. + +**Q: What if my ngrok domain changes?** +A: You'll need to update the MCP Server URL in ChatGPT Desktop settings. + +**Q: Can I use this without WSL?** +A: The current setup requires WSL for ROS compatibility on Windows. Linux and macOS users can run directly. + +## 6. Tested Configurations + +### Host Machine (Windows) +* Windows 11 +* WSL with Ubuntu 22.04 +* ChatGPT Desktop +* ngrok (free/paid) + +### Target Machine (Windows/WSL) +* WSL with Ubuntu 22.04 +* ROS 2 Jazzy +* rosbridge_server + +### Host Machine (macOS) +* macOS (Apple Silicon / ARM64) +* Python 3.13, uv (via pip or Homebrew) +* MCP server: streamable-http on localhost:9000 + +### Target Machine (macOS via Docker) +* Docker Desktop for Mac +* ROS 2 Humble in Docker container ([5_docker_turtlesim](../5_docker_turtlesim/)) +* rosbridge_server on port 9090 + +> **Tested 2026-03-29**: MCP server started on macOS, connected to rosbridge in Docker, successfully called MCP tools (connect_to_robot, get_topics, get_nodes, publish_once, call_service). + +## 7. Support and Contributing + +### Getting Help +- **Issues**: Report bugs and request features on [GitHub Issues](https://github.com/robotmcp/ros-mcp-server/issues) +- **Documentation**: Check the main [README.md](../../README.md) for additional information +- **Community**: Join discussions in the project's GitHub Discussions + +### Contributing +We welcome contributions! Please see our [Contributing Guide](../../docs/contributing.md) for details on: +- Setting up a development environment +- Code style guidelines +- Submitting pull requests +- Testing procedures \ No newline at end of file diff --git a/examples/7_cursor/README.md b/examples/7_cursor/README.md new file mode 100644 index 0000000..de4d41e --- /dev/null +++ b/examples/7_cursor/README.md @@ -0,0 +1,320 @@ +# Cursor Desktop with ROS-MCP-Server + +> **See also:** The [Cursor setup guide](../../docs/install/clients/cursor.md) in the main installation docs for a streamlined setup. + +## Prerequisites + +### Prepare host machine (MCP Server) + +* Installation of Cursor Desktop. Download [here](https://cursor.com/downloads). +* Installation of WSL on Windows + +### Prepare target robot (ROS) + +* Installation of Ubuntu or WSL (Windows Subsystem for Linux) on Windows. +* Installation of ROS or ROS2. Test if ROS is installed by running Turtlesim. If you are not sure, follow this tutorial. See [here](https://wiki.ros.org/ROS/Tutorials). + +# Tutorial + +## Quick Start (For Experienced Users) + +1. **Install dependencies**: `curl -LsSf https://astral.sh/uv/install.sh | sh` +2. **Clone repository**: `cd ~ && git clone https://github.com/robotmcp/ros-mcp-server.git && cd ros-mcp-server` +3. **Configure Cursor**: Add stdio transport to `mcp.json` (see Option 2 below) +4. **Start ROS**: `ros2 launch rosbridge_server rosbridge_websocket_launch.xml & ros2 run turtlesim turtlesim_node` +5. **Test**: Ask Cursor to "List ROS topics" + +## 1.1 Installation on Host Machine + +### Install dependencies + +* Install [`uv`](https://github.com/astral-sh/uv) using one of the following methods: + +
+ Option A: PowerShell (Windows) + + ```bash + winget install --id=astral-sh.uv -e + ``` + or + ```bash + pip install uv + ``` + +
+ +
+ Option B: WSL (Linux) + + ```bash + curl -LsSf https://astral.sh/uv/install.sh | sh + ``` + or (not recommended) + ```bash + pip install uv + ``` + or (not recommended) + ```bash + sudo snap install --classic astral-uv + ``` +
+ + + +### Install ROS-MCP Server + +* On your Host Machine, clone the repository and navigate to the ROS-MCP Server folder. + +```bash +cd ~ +git clone https://github.com/robotmcp/ros-mcp-server.git +cd ros-mcp-server +``` + +> โš ๏ธ **WSL Users**: Clone the repository in your WSL home directory (e.g., `/home/username/`) instead of the Windows filesystem mount (e.g., `/mnt/c/Users/username/`). Using the native Linux filesystem provides better performance and avoids potential permission issues. + +## 1.4 Connect ROS-MCP to Cursor + +* Open Cursor Desktop +* Navigate to Settings (gear icon top right) +* Open MCP > New MCP Server +* Modify the `mcp.json` as follows: + +### Option 1: HTTP Transport (Network-based) + +**Note**: For HTTP transport, you need to manually start the MCP server first. + +* Start the MCP server in WSL: +```bash +wsl +cd ros-mcp-server +uv run server.py --transport http +``` + +* Configure Cursor with the following: +``` +{ + "mcpServers": { + "ros-mcp-server-http": { + "name": "ROS-MCP Server (http)", + "transport": "http", + "url": "http://127.0.0.1:9000/mcp" + } + } +} +``` + +### Option 2: Stdio Transport (Direct WSL execution) - **Recommended** + +**Benefits of stdio transport:** +- No need to manually start the server +- Direct communication with WSL +- More reliable and faster +- Automatic server management + +**Important Configuration Notes:** +- Make sure to clone the ros-mcp-server folder to your `/home/` directory (note: ~ for home directory may not work in JSON files) +``` +"/home//ros-mcp-server" # Recommended: WSL/Linux home directory +``` +- **Avoid using `/mnt/c/Users//` paths** - these point to the Windows filesystem which can cause performance issues and permission problems +- Use the correct WSL distribution name (e.g., "Ubuntu" or "Ubuntu-22.04") +- Make sure to replace `` with your actual WSL username + +- Ensure uv is installed at `/home//.local/bin/uv`, if not modify the installation directory (`which uv` can help locate.) + +``` +{ + "mcpServers": { + "ros-mcp-server": { + "name": "ROS-MCP Server (stdio)", + "transport": "stdio", + "command": "wsl", + "args": [ + "-d", "Ubuntu", + "/home//.local/bin/uv", + "--directory", + "/home//ros-mcp-server", + "run", + "server.py" + ] + } +} +``` + +### Option 3: Both HTTP and Stdio (Complete Configuration) + +You can copy-paste the mcp.json in this folder and modify accordingly: + +``` +{ + "mcpServers": { + "ros-mcp-server-http": { + "name": "ROS-MCP Server (http)", + "transport": "http", + "url": "http://127.0.0.1:9000/mcp" + }, + "ros-mcp-server-stdio": { + "name": "ROS-MCP Server (stdio)", + "transport": "stdio", + "command": "wsl", + "args": [ + "-d", "Ubuntu", + "/home//.local/bin/uv", + "--directory", + "/home//ros-mcp-server", + "run", + "server.py" + ] + } + } +} +``` + +## 1.5 Run ROS on Target Machine + +In WSL, launch `rosbridge` and `turtlesim` + +```bash +source /opt/ros/jazzy/setup.bash +ros2 launch rosbridge_server rosbridge_websocket_launch.xml & ros2 run turtlesim turtlesim_node +``` + +or + +```bash +launch_ros.sh +``` + +## 1.6 Test Your Setup + +Once everything is configured, test your connection: + +1. **Start a new chat in Cursor** +2. **Try a simple ROS command** like: + - "List all available ROS topics" + - "Show me the current ROS node information" + - "What robots are available in the specifications?" + +3. **Verify the connection** - you should see ROS-related responses from the MCP server + +## 2. Troubleshooting + +### Common Issues + +**MCP Server not connecting:** +- Verify WSL is running: `wsl --list --verbose` +- Check if uv is installed: `wsl -- which uv` +- Ensure the ros-mcp-server directory exists in WSL + +**ROS Bridge connection failed:** +- Make sure ROS is running: `ros2 node list` +- Verify rosbridge is active: `ros2 launch rosbridge_server rosbridge_websocket_launch.xml` +- Check if port 9090 is available + +**Stdio transport not working:** +- Verify the WSL distribution name is correct +- Check if the uv path is accurate: `/home//.local/bin/uv` +- Ensure the ros-mcp-server directory is in the correct location `/home//ros-mcp-server` + +### Debug Commands + +```bash +# Test WSL connection +wsl --list --verbose + +# Test uv installation +wsl -- which uv + +# Test ROS installation +wsl -- ros2 node list + +# Test rosbridge +wsl -- ros2 topic list +``` + +## 3. Usage Examples + +Once connected, you can use natural language to interact with your ROS system: + +- **"Move the turtle forward"** - Controls turtlesim +- **"What topics are currently publishing?"** - Lists active topics +- **"Show me the robot specifications"** - Displays available robot configs +- **"Connect to robot at IP 192.168.1.100"** - Connects to remote robot +- **"Take a picture with the camera"** - Captures camera data + +## 4. Advanced Configuration + +### Environment Variables + +You can customize the MCP server behavior with these environment variables: + +```bash +# ROS Bridge settings +export ROSBRIDGE_IP="127.0.0.1" # Default: localhost +export ROSBRIDGE_PORT="9090" # Default: 9090 + +# MCP Transport settings +export MCP_TRANSPORT="streamable-http" # For ChatGPT: streamable-http +export MCP_TRANSPORT="stdio" # For Cursor: stdio (recommended) +export MCP_HOST="127.0.0.1" # For HTTP transport +export MCP_PORT="9000" # For HTTP transport +``` + +### Custom Robot Specifications + +Add your own robot configurations in `utils/robot_specifications/`: + +```yaml +# utils/robot_specifications/my_robot.yaml +name: "My Custom Robot" +ip: "192.168.1.100" +port: 9090 +description: "My custom robot configuration" +``` + +### Multiple Robot Support + +You can connect to multiple robots by switching configurations: + +```bash +# Connect to different robots +export ROSBRIDGE_IP="192.168.1.100" # Robot 1 +export ROSBRIDGE_IP="192.168.1.101" # Robot 2 +``` + +## 5. FAQ + +**Q: Can I run multiple MCP servers?** +A: Yes, you can configure multiple servers in your mcp.json file. + +**Q: Is stdio transport faster than HTTP?** +A: Yes, stdio transport is generally faster and more reliable as it eliminates network overhead. + +**Q: Can I use this without WSL?** +A: The current setup requires WSL for ROS compatibility on Windows. Linux and macOS users can run directly. + +## 6. Tested Configurations + +### Host Machine +* Windows 11 +* WSL with Ubuntu 22.04 +* Cursor Desktop + +### Target Machine +* WSL with Ubuntu 22.04 +* ROS 2 Jazzy + +## 7. Support and Contributing + +### Getting Help +- **Issues**: Report bugs and request features on [GitHub Issues](https://github.com/robotmcp/ros-mcp-server/issues) +- **Documentation**: Check the main [README.md](../../README.md) for additional information +- **Community**: Join discussions in the project's GitHub Discussions + +### Contributing +We welcome contributions! Please see our [Contributing Guide](../../docs/contributing.md) for details on: +- Setting up a development environment +- Code style guidelines +- Submitting pull requests +- Testing procedures \ No newline at end of file diff --git a/examples/8_images/README.md b/examples/8_images/README.md new file mode 100644 index 0000000..8f88785 --- /dev/null +++ b/examples/8_images/README.md @@ -0,0 +1,433 @@ +# Tutorial - ROS MCP Server with Image Processing + +Welcome to the image processing tutorial! This guide will walk you through using the ROS MCP Server to work with camera feeds, analyze images, and perform computer vision tasks using natural language commands. + +## What You'll Learn + +By the end of this tutorial, you'll be able to: +- Launch camera feeds using different camera types (synthetic or real) +- Capture and analyze images from camera topics +- Count objects in images +- Detect movement between frames +- Control image processing parameters +- Use natural language to interact with camera systems + +## Prerequisites + +Before starting this tutorial, make sure you have: + +โœ… **ROS2 installed** (Humble or Jazzy recommended) +โœ… **Basic familiarity with terminal/command line** +โœ… **The ROS MCP Server installed** (see [Installation Guide](../../docs/install/installation.md)) +โœ… **OpenCV and image processing libraries** (usually included with ROS2) + +> โš ๏ธ **macOS/Windows Users**: This tutorial requires ROS2 packages installed via `apt`, which is only available on Linux. If you don't have a native Linux environment, see **Option 3: Docker-Based Camera** below for a setup that works on macOS and Windows. + +## Camera Options + +This tutorial supports two camera types: + +### ๐ŸŽฎ **Option 1: Synthetic Camera (image_tools)** +- **Best for**: Learning, testing, and development +- **Requirements**: Only ROS2 and image_tools + +### ๐Ÿ“ท **Option 2: RealSense Camera (realsense2_camera)** +- **Best for**: Real-world applications and advanced computer vision +- **Requirements**: Intel RealSense camera + realsense2_camera package + +### ๐Ÿณ **Option 3: Docker-Based Camera (No native ROS2 required)** +- **Best for**: macOS and Windows users without native ROS2 +- **Requirements**: Docker Desktop + +This option uses the existing [5_docker_turtlesim](../5_docker_turtlesim/) Docker container (which includes rosbridge) and extends it with image_tools. + +#### Setup + +```bash +# Step 1: Start the turtlesim Docker container (includes rosbridge on port 9090) +cd ../5_docker_turtlesim +docker compose up -d turtlesim + +# Step 2: Install image_tools inside the running container +docker exec -it ros2-turtlesim bash -c "\ + apt-get update && \ + apt-get install -y ros-\${ROS_DISTRO}-image-tools ros-\${ROS_DISTRO}-image-transport-plugins && \ + source /opt/ros/\${ROS_DISTRO}/setup.bash && \ + ros2 run image_tools cam2image --ros-args -p burger_mode:=true" +``` + +> ๐Ÿ’ก **Note**: The rosbridge on port 9090 is already exposed by the Docker container. The MCP server on your host can connect to it directly. +> +> The `showimage` GUI display requires X11 forwarding. On macOS, install [XQuartz](https://www.xquartz.org/) first. If you skip display, image capture via MCP still works headlessly. + +After the container is running with `cam2image`, continue to **Step 2** to verify the system, and **Step 3** to connect with MCP. + +## Dependencies + +### Required for All Camera Types + +Install the ROS image transport plugins package (replace `${ROS_DISTRO}` with your current ROS 2 distribution, for example `humble` or `jazzy`): + +```bash +sudo apt install ros-${ROS_DISTRO}-image-transport-plugins +``` + +### For Synthetic Camera (image_tools) + +Install image_tools for synthetic camera data: + +```bash +sudo apt install ros-${ROS_DISTRO}-image-tools +``` + +### For RealSense Camera + +Install the RealSense ROS2 package: + +```bash +sudo apt install ros-${ROS_DISTRO}-realsense2-camera +``` + +> ๐Ÿ’ก **Tip**: Start with the synthetic camera option for learning, then move to RealSense for real-world applications. + +## Step 1: Launch the Image Demo System + +Choose your camera type and launch the appropriate system: + +### ๐ŸŽฎ **Option A: Synthetic Camera (Burger) - Recommended for Beginners** + +#### Using Launch File (Easiest) + +```bash +# Navigate to the examples directory +cd examples/8_images + +# Launch synthetic camera system +ros2 launch ros_mcp_images_demo.launch.py +``` + +#### Manual Launch (For Learning) + +```bash +# Terminal 1: Start rosbridge +ros2 launch rosbridge_server rosbridge_websocket_launch.xml + +# Terminal 2: Start synthetic camera feed +ros2 run image_tools cam2image --ros-args -p burger_mode:=true + +# Terminal 3: Display images +ros2 run image_tools showimage + +# Terminal 4: Start image compression +ros2 run image_transport republish raw in:=/image out:=/image/compressed +``` + +### ๐Ÿ“ท **Option B: RealSense Camera - For Real-World Applications** + +#### Using Launch File (Easiest) + +```bash +# Navigate to the examples directory +cd examples/8_images + +# Launch RealSense camera system +ros2 launch ros_mcp_images_demo_realsense.launch.py +``` + +#### Manual Launch (For Learning) + +```bash +# Terminal 1: Start rosbridge +ros2 launch rosbridge_server rosbridge_websocket_launch.xml + +# Terminal 2: Start RealSense camera +ros2 launch realsense2_camera rs_launch.py + +# Terminal 3: Display color images +ros2 run image_tools showimage --ros-args --remap /image:=/camera/camera/color/image_raw + +# Terminal 4: Display depth images (optional) +ros2 run image_tools showimage --ros-args --remap /image:=/camera/camera/depth/image_rect_raw + +# Terminal 5: Start image compression +ros2 run image_transport republish raw in:=/camera/camera/color/image_raw out:=/camera/camera/color/image_raw/compressed +``` + +### What Each System Provides + +#### Synthetic Camera System: +- **rosbridge_server** - WebSocket server for MCP communication +- **cam2image** - Synthetic camera feed (burger images) +- **showimage** - Image display window +- **republish** - Image compression service + +#### RealSense Camera System: +- **rosbridge_server** - WebSocket server for MCP communication +- **realsense2_camera** - RealSense camera driver +- **showimage** - Image display windows for color and depth +- **republish** - Image compression service + +## Step 2: Verify the System is Running + +Check that all components are working: + +```bash +# List available topics +ros2 topic list +``` + +### For Synthetic Camera System, you should see: +``` +/image - Raw camera feed (burger images) +/image/compressed - Compressed camera feed +/flip_image - Image flip control +/client_count - Connection count +/connected_clients - Client information +``` + +### For RealSense Camera System, you should see: +``` +/camera/camera/color/image_raw - Color camera feed +/camera/camera/color/camera_info - Color camera calibration +/camera/camera/color/metadata - Color camera metadata +/camera/camera/depth/image_rect_raw - Depth camera feed +/camera/camera/depth/camera_info - Depth camera calibration +/camera/camera/depth/metadata - Depth camera metadata +/camera/camera/extrinsics/depth_to_color - Camera extrinsics +/client_count - Connection count +/connected_clients - Client information +``` + +### Test Camera Feed + +```bash +# For synthetic camera +ros2 topic echo /image --once + +# For RealSense camera +ros2 topic echo /camera/camera/color/image_raw --once +``` + +## Step 3: Connect with MCP Server + +Now let's connect the MCP server to the image system: + +### Start the MCP Server with HTTP + +```bash +# From the project root +cd /path/to/ros-mcp-server +export MCP_TRANSPORT=http +uv run server.py +``` + +### Connect to the System + +Once connected, you can start using natural language commands to interact with the camera system. + +## Step 4: Basic Image Operations + +### Capture Images + +Try these commands with your AI assistant: + +#### For Synthetic Camera: +``` +Read an image from the /image topic +``` + +``` +Capture the current burger image +``` + +``` +Take a picture from the synthetic camera +``` + +#### For RealSense Camera: +``` +Read an image from the RealSense camera +``` + +``` +Capture the current color camera feed +``` + +``` +Take a picture from /camera/camera/color/image_raw +``` + +### Analyze Images + +#### General Analysis: +``` +What do you see in this image? +``` + +``` +Count the objects in the image +``` + +``` +Describe what's in the camera feed +``` + +#### For Synthetic Camera: +``` +How many burgers are in the image? +``` + +``` +What color is the burger? +``` + +``` +Describe the synthetic camera scene +``` + +#### For RealSense Camera: +``` +What objects are visible in the room? +``` + +``` +Describe the scene from the RealSense camera +``` + +``` +What's the lighting like in the image? +``` + +## Step 5: Advanced Camera Control + +### Camera Parameters + +#### For Synthetic Camera: +``` +What are the current camera settings? +``` + +``` +Change the camera resolution +``` + +``` +Adjust the camera frequency +``` + +#### For RealSense Camera: +``` +What are the RealSense camera settings? +``` + +``` +Get the camera calibration information +``` + +``` +Check the depth camera parameters +``` + +## Troubleshooting + +### Common Issues + +
+No Image Display + +**Problem**: Camera feed not showing or no images received + +**Solutions**: +- Launch the server with HTTP transport. It seems stdio can have difficulties showing images in the chat. +- **For Synthetic Camera**: Check if cam2image is running: `ros2 node list | grep cam2image` +- **For RealSense Camera**: Check if realsense2_camera is running: `ros2 node list | grep realsense` +- Verify image topic exists: `ros2 topic list | grep image` +- **For Synthetic Camera**: Test image publishing: `ros2 topic echo /image --once` +- **For RealSense Camera**: Test image publishing: `ros2 topic echo /camera/camera/color/image_raw --once` + +
+ +
+RealSense Camera Issues + +**Problem**: RealSense camera not detected or not working + +**Solutions**: +- Check if camera is connected: `lsusb | grep Intel` +- Verify RealSense SDK installation: `realsense-viewer` +- Check camera permissions: `sudo usermod -a -G video $USER` (then logout/login) +- Test with RealSense viewer: `realsense-viewer` +- Check ROS2 RealSense package: `ros2 pkg list | grep realsense` + +
+ +
+MCP Connection Issues + +**Problem**: AI assistant can't access camera data + +**Solutions**: +- Verify that you are configuring your MCP server correctly +- First connect to the MCP server with `connect_to_robot` tool +- Ensure MCP server is connected +- Restart rosbridge if connection fails + +
+ +
+Image Processing Errors + +**Problem**: Image analysis commands fail + +**Solutions**: +- Check if OpenCV is properly installed +- **For Synthetic Camera**: Verify image message format: `ros2 topic info /image` +- **For RealSense Camera**: Verify image message format: `ros2 topic info /camera/camera/color/image_raw` +- Test with simpler commands first + +
+ +
+Display Issues + +**Problem**: showimage window doesn't appear + +**Solutions**: +- **WSL users**: Install X11 forwarding: `sudo apt install x11-apps` +- **Remote connections**: Use X11 forwarding: `ssh -X username@hostname` +- **Docker users**: Check X11 forwarding configuration +- **macOS users**: Install [XQuartz](https://www.xquartz.org/) (`brew install --cask xquartz`), log out and back in, then run `xhost +localhost` before launching Docker. Alternatively, skip the GUI โ€” image capture via MCP still works without display. +- **For Synthetic Camera**: Try running without display: `ros2 run image_tools cam2image --ros-args -p show_camera:=false` +- **For RealSense Camera**: Try running without display: `ros2 launch realsense2_camera rs_launch.py enable_color:=true enable_depth:=true` + +
+ +
+Topic Not Found + +**Problem**: Expected camera topics not available + +**Solutions**: +- **For Synthetic Camera**: Ensure cam2image is running with correct parameters +- **For RealSense Camera**: Check if camera is properly connected and drivers are loaded +- List all available topics: `ros2 topic list` +- Check topic info: `ros2 topic info ` +- Verify camera launch parameters + +
+ +
+No Native ROS2 on macOS + +**Problem**: `sudo apt install ros-*` commands not available on macOS + +**Solution**: Use the Docker-based approach described in **Option 3** above. The [5_docker_turtlesim](../5_docker_turtlesim/) example provides a ready-made Docker container with rosbridge that can be extended with `image_tools`. See the [Docker setup instructions](#-option-3-docker-based-camera-no-native-ros2-required) for step-by-step guidance. + +
+ +## Learning Resources + +- [ROS2 Image Processing Tutorials](https://docs.ros.org/en/humble/Tutorials/Intermediate/Image_Processing/) +- [OpenCV with ROS2](https://docs.ros.org/en/humble/Tutorials/Intermediate/Image_Processing/) +- [Computer Vision with ROS](https://wiki.ros.org/cv_bridge) +- [Image Transport Tutorials](https://docs.ros.org/en/humble/Tutorials/Intermediate/Image_Processing/) diff --git a/examples/8_images/ros_mcp_images_demo.launch.py b/examples/8_images/ros_mcp_images_demo.launch.py new file mode 100644 index 0000000..968f2f0 --- /dev/null +++ b/examples/8_images/ros_mcp_images_demo.launch.py @@ -0,0 +1,65 @@ +import os + +from ament_index_python.packages import get_package_share_directory +from launch.actions import DeclareLaunchArgument, IncludeLaunchDescription +from launch.launch_description_sources import AnyLaunchDescriptionSource +from launch.substitutions import LaunchConfiguration +from launch_ros.actions import Node + +from launch import LaunchDescription + + +def generate_launch_description(): + # Declare port argument for rosbridge + port_arg = DeclareLaunchArgument( + "port", default_value="9090", description="Port for rosbridge websocket" + ) + + # rosbridge websocket + rosbridge_launch = IncludeLaunchDescription( + AnyLaunchDescriptionSource( + os.path.join( + get_package_share_directory("rosbridge_server"), + "launch", + "rosbridge_websocket_launch.xml", + ) + ), + launch_arguments={"port": LaunchConfiguration("port")}.items(), + ) + + # cam2image (synthetic image since burger_mode=true) + cam2image = Node( + package="image_tools", + executable="cam2image", + name="cam2image", + parameters=[{"burger_mode": True}], + ) + + # showimage (display the image) + showimage = Node( + package="image_tools", + executable="showimage", + name="showimage", + ) + + # image_transport republisher (raw โ†’ compressed) + republish = Node( + package="image_transport", + executable="republish", + name="republish", + arguments=["raw"], + remappings=[ + ("in", "/image"), + ("out", "/image/compressed"), + ], + ) + + return LaunchDescription( + [ + port_arg, + rosbridge_launch, + cam2image, + showimage, + republish, + ] + ) diff --git a/examples/8_images/ros_mcp_images_demo_realsense.launch.py b/examples/8_images/ros_mcp_images_demo_realsense.launch.py new file mode 100644 index 0000000..512c42d --- /dev/null +++ b/examples/8_images/ros_mcp_images_demo_realsense.launch.py @@ -0,0 +1,69 @@ +import os + +from ament_index_python.packages import get_package_share_directory +from launch.actions import DeclareLaunchArgument, IncludeLaunchDescription +from launch.launch_description_sources import AnyLaunchDescriptionSource +from launch.substitutions import LaunchConfiguration +from launch_ros.actions import Node + +from launch import LaunchDescription + + +def generate_launch_description(): + # Declare port argument for rosbridge + port_arg = DeclareLaunchArgument( + "port", default_value="9090", description="Port for rosbridge websocket" + ) + + # rosbridge websocket + rosbridge_launch = IncludeLaunchDescription( + AnyLaunchDescriptionSource( + os.path.join( + get_package_share_directory("rosbridge_server"), + "launch", + "rosbridge_websocket_launch.xml", + ) + ), + launch_arguments={"port": LaunchConfiguration("port")}.items(), + ) + + # Realsense camera driver + realsense_launch = IncludeLaunchDescription( + AnyLaunchDescriptionSource( + os.path.join( + get_package_share_directory("realsense2_camera"), + "launch", + "rs_launch.py", + ) + ) + ) + + # showimage for realsense (color) + showimage_real = Node( + package="image_tools", + executable="showimage", + name="showimage_real", + remappings=[("/image", "/camera/camera/color/image_raw")], + ) + + # republish compressed for realsense color stream + republish_real = Node( + package="image_transport", + executable="republish", + name="republish_real", + arguments=["raw"], + remappings=[ + ("in", "/camera/camera/color/image_raw"), + ("out", "/camera/camera/color/image_raw/compressed"), + ], + ) + + return LaunchDescription( + [ + port_arg, + rosbridge_launch, + realsense_launch, + showimage_real, + republish_real, + ] + ) diff --git a/examples/9_turtlebot3/README.md b/examples/9_turtlebot3/README.md new file mode 100644 index 0000000..b5856ef --- /dev/null +++ b/examples/9_turtlebot3/README.md @@ -0,0 +1,99 @@ +# Example - Turtlebot3 (Real) + +![Static Badge](https://img.shields.io/badge/ROS2-Available-green) + +This example demonstrates the use of a real TurtleBot3 Burger robot with ROS2. +The TurtleBot3 is a compact, customizable mobile robot designed for education and research. It supports applications such as SLAM, navigation, and autonomous control, and comes in various hardware configurations. + + +## Prerequisites + +For this example, the TurtleBot3 Burger version with Ubuntu 22.04 and ROS2 Humble is used. Depending on your TurtleBot3 model and installed hardware (e.g., LDS-01 LiDAR), make sure to install the corresponding packages required for your specific configuration. + +### Turtlebot3 + +For more details, please refer to the [TurtleBot3 Documentation](https://emanual.robotis.com/docs/en/platform/turtlebot3/overview/). + + + +- **Specification** + - **OS** : Ubuntu 22.04 + - **ROS** : ROS2 Humble + - **TurtleBot3 Model** : Burger + - **LiDAR** : LDS-01 + - **Computer** : Raspberry Pi 3 Model B+ (1GB RAM) + - **Motor Controller** : OpenCR 1.0 (Firmware v0.2.1) + +## Quick Start + +### 1. Network Setup + +Since the TurtleBot3 is controlled via ROS-MCP from the user PC, it is important to connect both the user PC and the TurtleBot3 to the same network. +> **Note:** You can check your network IP address with the `ifconfig` command. + +**Ping Test** + +After connecting them to the same network, perform a ping test from the user PC to TurtleBot3 to verify that the connection is established correctly: +**[User's PC]** +```bash +ping # e.g., ping 192.168.101.166 +``` + +**ROS2 Network Setup** + +In ROS2, environment variables are used to configure Domain ID and ROS middleware behavior. +On both the user PC and TurtleBot3, export: +**[User's PC]** **[Turtlebot3 SBC]** +```bash +echo "export ROS_DOMAIN_ID=30" >> ~/.bashrc +echo "export RMW_IMPLEMENTATION=rmw_fastrtps_cpp" >> ~/.bashrc +source ~/.bashrc +``` + +### 2. Bringup Turtlebot3 + +Open a new terminal on the user PC and connect to the Raspberry Pi via SSH using its IP address. Enter your Ubuntu OS password for the Raspberry Pi. + +**[User's PC]** +```bash +ssh ubuntu@{IP_ADDRESS_OF_RASPBERRY_PI} +``` + +Bring up basic packages to start essential TurtleBot3 applications. You will need to specify your TurtleBot3 model. + +**[Turtlebot3 SBC]** +```bash +export TURTLEBOT3_MODEL=burger +ros2 launch turtlebot3_bringup robot.launch.py +``` + +### 3. Launch Node on Your Turtlebot3 SBC and Open claude-desktop on Your PC + +**[Turtlebot3 SBC]** +```bash +ros2 launch rosbridge_server rosbridge_websocket_launch.xml +``` + +**[User's PC]** +```bash +claude-desktop +``` + +## **Example Walkthrough** +You're now ready to interact with the TurtleBot3 via the ROS MCP server. Follow these examples step-by-step: + +### **Example 1**: Connect to Robot + + + +### **Example 2**: Check Available Topics + + + + +### **Example 3**: Move the Robot Back and Forth + + + +## **Next Steps** +If your TurtleBot3 is equipped with a Raspberry Pi camera, you can now start streaming visual data. Try integrating camera feeds into your pipeline for more advanced robot control demos. \ No newline at end of file diff --git a/examples/9_turtlebot3/images/turtlebot3_connect.png b/examples/9_turtlebot3/images/turtlebot3_connect.png new file mode 100644 index 0000000..09160ae Binary files /dev/null and b/examples/9_turtlebot3/images/turtlebot3_connect.png differ diff --git a/examples/9_turtlebot3/images/turtlebot3_example3.gif b/examples/9_turtlebot3/images/turtlebot3_example3.gif new file mode 100644 index 0000000..e78a5f3 Binary files /dev/null and b/examples/9_turtlebot3/images/turtlebot3_example3.gif differ diff --git a/examples/9_turtlebot3/images/turtlebot3_gettopics_1.png b/examples/9_turtlebot3/images/turtlebot3_gettopics_1.png new file mode 100644 index 0000000..2fdaa36 Binary files /dev/null and b/examples/9_turtlebot3/images/turtlebot3_gettopics_1.png differ diff --git a/examples/9_turtlebot3/images/turtlebot3_gettopics_2.png b/examples/9_turtlebot3/images/turtlebot3_gettopics_2.png new file mode 100644 index 0000000..87d2979 Binary files /dev/null and b/examples/9_turtlebot3/images/turtlebot3_gettopics_2.png differ diff --git a/examples/9_turtlebot3/images/turtlebot3_image.png b/examples/9_turtlebot3/images/turtlebot3_image.png new file mode 100644 index 0000000..a57729e Binary files /dev/null and b/examples/9_turtlebot3/images/turtlebot3_image.png differ diff --git a/examples/9_turtlebot3/ros2_mcp_turtlebot3.launch.py b/examples/9_turtlebot3/ros2_mcp_turtlebot3.launch.py new file mode 100644 index 0000000..42e8c2b --- /dev/null +++ b/examples/9_turtlebot3/ros2_mcp_turtlebot3.launch.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 + +""" +ROS2 Launch file for ROS-MCP Server with TurtleBot3 +This launch file starts: +- rosbridge_websocket server +- TurtleBot3 robot +- Provides proper process management and cleanup +""" + +import os + +from launch.actions import DeclareLaunchArgument, IncludeLaunchDescription, LogInfo +from launch.launch_description_sources import PythonLaunchDescriptionSource +from launch.substitutions import LaunchConfiguration +from launch_ros.actions import Node +from launch_ros.substitutions import FindPackageShare + +from launch import LaunchDescription + + +def generate_launch_description(): + """Generate the launch description for ROS-MCP Server with TurtleBot3.""" + + # Declare launch arguments + rosbridge_port_arg = DeclareLaunchArgument( + "port", default_value="9090", description="Port for rosbridge websocket server" + ) + + rosbridge_address_arg = DeclareLaunchArgument( + "address", + default_value="", + description="Address for rosbridge websocket server (empty for all interfaces)", + ) + + # Rosbridge websocket server node + rosbridge_node = Node( + package="rosbridge_server", + executable="rosbridge_websocket", + name="rosbridge_websocket", + output="screen", + parameters=[ + { + "port": LaunchConfiguration("port"), + "address": LaunchConfiguration("address"), + "use_compression": False, + "max_message_size": 10000000, + "send_action_goals_in_new_thread": True, + "call_services_in_new_thread": True, + } + ], + arguments=["--ros-args", "--log-level", "info"], + ) + + # Include TurtleBot3 bringup launch file + turtlebot3_bringup_launch = IncludeLaunchDescription( + PythonLaunchDescriptionSource( + [ + os.path.join( + FindPackageShare("turtlebot3_bringup").find("turtlebot3_bringup"), + "launch", + "robot.launch.py", + ) + ] + ) + ) + + # Log info about what's being launched + log_info = LogInfo( + msg=[ + "Starting ROS-MCP Server with:", + " - Rosbridge WebSocket on port: ", + LaunchConfiguration("port"), + " - TurtleBot3 robot bringup", + ] + ) + + return LaunchDescription( + [ + rosbridge_port_arg, + rosbridge_address_arg, + log_info, + rosbridge_node, + turtlebot3_bringup_launch, + ] + ) diff --git a/launch/launch_mcp_server.sh b/launch/launch_mcp_server.sh new file mode 100644 index 0000000..191d14e --- /dev/null +++ b/launch/launch_mcp_server.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -eo pipefail + +# Colors for log output +BLUE='\033[1;34m' +GREEN='\033[1;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo -e "${BLUE}Starting MCP server environment...${NC}" + +# 0. Use command line arguments for MCP configuration +# Default: streamable-http transport on localhost:9000 +TRANSPORT=${MCP_TRANSPORT:-"streamable-http"} +HOST=${MCP_HOST:-"127.0.0.1"} +PORT=${MCP_PORT:-"9000"} + +# 1. Check if MCP server is running +if pgrep -f ros-mcp-server > /dev/null; then + echo -e "${YELLOW}[mcp-server]${NC} is already running. Continuing without starting a new instance." + MCP_PID=$(pgrep -f ros-mcp-server | head -n1) +else + # 2. Start MCP server with command line arguments + echo -e "${GREEN}[mcp-server]${NC} Launching MCP server with transport=$TRANSPORT, host=$HOST, port=$PORT..." + # Run with uv using command line arguments + uv run ../server.py --transport "$TRANSPORT" --host "$HOST" --port "$PORT" & + MCP_PID=$! +fi + +echo -e "${GREEN}[mcp-server]${NC} mcp-server PID: $MCP_PID" + +# 3. Trap to clean up processes on exit +trap "echo -e '${GREEN}[mcp-server] ${NC}Stopping MCP processes...'; kill -9 $MCP_PID" SIGINT SIGTERM + +# 4. Keep script running, showing logs +wait diff --git a/launch/launch_mcp_tunnel.sh b/launch/launch_mcp_tunnel.sh new file mode 100644 index 0000000..c8dd39a --- /dev/null +++ b/launch/launch_mcp_tunnel.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -eo pipefail + +# Colors for log output +BLUE='\033[1;34m' +GREEN='\033[1;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +DOMAIN=${MCP_DOMAIN:-"mazie-nonmodificatory-noncontemporaneously.ngrok-free.app"} +PORT=${MCP_PORT:-"9000"} + +echo -e "${BLUE}Starting MCP ngrok tunneling...${NC}" + +# 1. Check if MCP server is running +if pgrep -f ngrok > /dev/null; then + echo -e "${YELLOW}[mcp-server]${NC} is already running. Continuing without starting a new instance." + NGROK_PID=$(pgrep -f ngrok | head -n1) +else + # 2. Start ngrok tunnel + echo -e "${GREEN}[mcp-ngrok]${NC} Exposing MCP server via ngrok tunnel..." + ngrok http --url=${DOMAIN} ${PORT} & + NGROK_PID=$! +fi + +echo -e "${GREEN}[mcp-ngrok]${NC} mcp-ngrok PID: $NGROK_PID" + +# 3. Trap to clean up processes on exit +trap "echo -e '${GREEN}[mcp-ngrok] ${NC}Stopping ngrok processes...'; kill -9 $NGROK_PID" SIGINT SIGTERM + +# 4. Keep script running, showing logs +wait diff --git a/launch/launch_rosbridge.launch.py b/launch/launch_rosbridge.launch.py new file mode 100644 index 0000000..caf17f3 --- /dev/null +++ b/launch/launch_rosbridge.launch.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 + +""" +ROS2 Launch file for Rosbridge WebSocket Server only +Launches only the rosbridge server. + +Use this when you want to connect to an external robot or simulation. +""" + +from launch.actions import DeclareLaunchArgument, LogInfo +from launch.substitutions import LaunchConfiguration +from launch_ros.actions import Node + +from launch import LaunchDescription + + +def generate_launch_description(): + """Generate the launch description for rosbridge only.""" + + # Add here your robot nodes + # robot_node = Node( + # package='my_robot_pkg', + # executable='robot_node', + # name='my_robot' + # ) + + # Declare launch arguments + port_arg = DeclareLaunchArgument( + "port", default_value="9090", description="Port for rosbridge websocket server" + ) + + address_arg = DeclareLaunchArgument( + "address", + default_value="", + description="Address for rosbridge websocket server (empty for all interfaces)", + ) + + log_level_arg = DeclareLaunchArgument( + "log_level", default_value="info", description="Log level for rosbridge server" + ) + + # Rosbridge websocket server node + rosbridge_node = Node( + package="rosbridge_server", + executable="rosbridge_websocket", + name="rosbridge_websocket", + output="screen", + parameters=[ + { + "port": LaunchConfiguration("port"), + "address": LaunchConfiguration("address"), + "use_compression": False, + "max_message_size": 10000000, + "send_action_goals_in_new_thread": True, + "call_services_in_new_thread": True, + "default_call_service_timeout": 5.0, + } + ], + arguments=["--ros-args", "--log-level", LaunchConfiguration("log_level")], + ) + + # Log info + log_info = LogInfo( + msg=[ + "Starting Rosbridge WebSocket Server:", + " - Port: ", + LaunchConfiguration("port"), + " - Address: ", + LaunchConfiguration("address"), + " - Log level: ", + LaunchConfiguration("log_level"), + ] + ) + + return LaunchDescription( + [ + port_arg, + address_arg, + log_level_arg, + log_info, + rosbridge_node, + ] + ) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2e3954b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,81 @@ +[project] +name = "ros-mcp" +version = "3.1.0" +description = "Connect AI Language Models with Robots on ROS using MCP" +readme = "README.md" +requires-python = ">=3.10" +license = "Apache-2.0" +authors = [ + { name = "Rohit John Varghese"}, + { name = "Jungsoo Lee"}, + { name = "Youngmok Yun"}, + { name = "Stefano Dalla Gasperina"} +] + + +dependencies = [ + "fastmcp>=2.11.3", + "jsonschema>=4.25.1", + "mcp[cli]>=1.13.0", + "opencv-python>=4.11.0.86", + "pillow>=11.3.0", + "websocket-client>=1.8.0", +] + +[project.optional-dependencies] +dev = [ + "ruff", # single tool for linting & formatting + "pytest", # for tests + "pytest-timeout", # test timeouts +] + +[project.scripts] +ros-mcp = "ros_mcp.main:main" + +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +include-package-data = true +packages = ["ros_mcp", "ros_mcp.tools", "ros_mcp.utils", "ros_mcp.prompts", "ros_mcp.resources", "config", "robot_specifications"] + +[tool.setuptools.package-data] +robot_specifications = ["*.yaml"] + +[tool.setuptools.data-files] +"." = ["server.json"] + +# ---------------- Ruff configuration ---------------- +[tool.ruff] +line-length = 100 +target-version = "py310" + +# Linter config moved to new section +[tool.ruff.lint] +select = ["E", "F", "I"] # basic errors, pyflakes, import sorting +ignore = ["E501"] # ignore long-line warnings (optional) + +# Formatter config stays here +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +docstring-code-format = true + +# ---------------- Pytest configuration ---------------- +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = "test_*.py" +python_classes = "Test*" +python_functions = "test_*" +addopts = "-v --tb=short -p no:launch_testing -p no:launch_ros -p no:ament_flake8 -p no:ament_copyright -p no:ament_lint -p no:ament_pep257 -p no:ament_xmllint" +markers = [ + "installation: installation tests using Docker containers", + "integration: integration tests requiring Docker + ROS", + "slow: marks tests as slow (deselect with '-m \"not slow\"')", +] + +# MCP Registry validation - required for PyPI package verification +[tool.mcp] +name = "io.github.robotmcp/ros-mcp-server" diff --git a/robot_specifications/YOUR_ROBOT_NAME.yaml b/robot_specifications/YOUR_ROBOT_NAME.yaml new file mode 100644 index 0000000..84fb273 --- /dev/null +++ b/robot_specifications/YOUR_ROBOT_NAME.yaml @@ -0,0 +1,20 @@ +# Robot configuration template +# Replace this text with specific instructions and information about your robot. +# Include details on how to control it, important topics, and any special commands. +# For example: +# - Control commands (e.g., movement, actions) +# - Sensor topics (e.g., camera, lidar) +# - Safety guidelines +# - Any custom messages or services used by the robot + +name: YOUR_ROBOT_NAME +alias: YOUR_ROBOT_ALIAS # Optional alias for easier reference +type: real # or 'sim' for simulation +prompts: | + Replace this text with specific instructions and information about your robot. + Include details on how to control it, important topics, and any special commands. + For example: + - Control commands (e.g., movement, actions) + - Sensor topics (e.g., camera, lidar) + - Safety guidelines + - Any custom messages or services used by the robot diff --git a/robot_specifications/__init__.py b/robot_specifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/robot_specifications/local_rosbridge.yaml b/robot_specifications/local_rosbridge.yaml new file mode 100644 index 0000000..d911e8d --- /dev/null +++ b/robot_specifications/local_rosbridge.yaml @@ -0,0 +1,13 @@ +# Robot configuration template +# Replace this text with specific instructions and information about your robot. +# Include details on how to control it, important topics, and any special commands. +# For example: +# - Control commands (e.g., movement, actions) +# - Sensor topics (e.g., camera, lidar) +# - Safety guidelines +# - Any custom messages or services used by the robot + +name: local_rosbridge +alias: turtlesim # Optional alias for easier reference +type: sim # or 'real' for simulation +prompts: The local_rosbridge is a generic local turtlesim robot. diff --git a/robot_specifications/unitree_go2.yaml b/robot_specifications/unitree_go2.yaml new file mode 100644 index 0000000..914d254 --- /dev/null +++ b/robot_specifications/unitree_go2.yaml @@ -0,0 +1,113 @@ +name: unitree_go2 +alias: go2 +type: real +prompts: | + The Unitree Go2 is a quadruped robot equipped with advanced sensors and mobility, designed for agile movement and versatile research or industrial applications. + The Unitree Go2 robot uses many custom messages. Therefore, you don't need to use functions to check the topics. + + Unitree Go2 Robot High-Level Control Information (RECOMMENDED): + The `go2_ws` workspace exposes robot actions as ROS 2 services. These are PREFERRED over manual topic publishing because they handle the control loop and logic automatically. + + Available Services: + 1. Posture Control (Type: `go2_interfaces/srv/Trigger`): + - `/go2/stand_up`: Stand up to default height. + - `/go2/stand_down`: Lie down. + - `/go2/sit`: Sit down. + - `/go2/recovery`: Recover from fall or unknown state. + - `/go2/balance`: Enter balance stand mode. + + 2. Movement Control (Type: `go2_interfaces/srv/Move`): + - Service: `/go2/move` + - Fields: `{vx: float, vy: float, vyaw: float, distance: float}` + - `vx`: Forward/backward velocity (m/s). + - `vy`: Left/right strafe velocity (m/s). + - `vyaw`: Rotation velocity (rad/s). + - `distance`: Target distance (for linear move) or angle (for rotation). + - Note: The robot will automatically walk for the calculated duration and then stop. + + 3. Tricks/Special Actions (Type: `go2_interfaces/srv/Trigger`): + - `/go2/dance1`, `/go2/dance2` + - `/go2/hello`, `/go2/stretch` + - `/go2/front_flip`, `/go2/back_flip` + + - Example Service Calls: + call_service(service='/go2/stand_up', type='go2_interfaces/srv/Trigger') + call_service(service='/go2/sit', type='go2_interfaces/srv/Trigger') + call_service(service='/go2/move', type='go2_interfaces/srv/Move', request={'vx': 0.5, 'vy': 0.0, 'vyaw': 0.0, 'distance': 2.0}) + + Unitree Go2 Robot Basic Control Information (Low-Level): + The Unitree Go2 robot is controlled via the /wirelesscontroller topic with the following parameters: + * ly: controls forward/backward movement, forward is positive, backward is negative (range: -1.0 to 1.0) + * lx: controls left/right movement, left is negative, right is positive (range: -1.0 to 1.0) + * rx: controls left/right rotation, left is negative, right is positive (range: -1.0 to 1.0) + * ry: controls forward/backward tilting, forward is positive, backward is negative (range: -1.0 to 1.0) + # keys: used for special actions (default is 0) + + Important: All movement commands max speed is 1.0 (full speed) equlals 1.0 m/s. + + If the Unitree Go2 moves using publish_for_durations, always set the last message to all zeros to stop the robot. + -Example: + publish_for_durations( + topic='/wirelesscontroller', + msg_type='unitree_msgs/msg/WirelessController', + messages=[{ + 'lx': 0.0, + 'ly': 0.5, # Move forward at half speed + 'rx': 0.0, + 'ry': 0.0, + 'keys': 0 + }, + { + 'lx': 0.0, + 'ly': 0.0, + 'rx': 0.0, + 'ry': 0.0, + 'keys': 0 + }], + durations=[2.0, 1.0] # Move for 2 seconds, then stop for 1 second + ) + + Unitree Go2 Robot Special Actions Information: + For special actions, use the keys field in the /wirelesscontroller topic: + CRITICAL RULE: ALL special actions MUST be sent as a pair using publish_for_durations with keys=1056 first, followed by the special action code. + Important: Always send keys=1056 first before attempting any special action. + * keys=1056: prerequisite command that MUST be sent once before any special action not just movement + * keys=257 : `jump_forward` + * keys=258 : `greet` + * keys=528 : `shake_hands` + * keys=1025 : `pounce` + * keys=513 : `sit_down` + * keys=514 : `dance` + + - Example: + publish_for_durations( + topic='/wirelesscontroller', + msg_type='unitree_msgs/msg/WirelessController', + messages=[{ + 'ly': 0.0, + 'lx': 0.0, + 'rx': 0.0, + 'ry': 0.0, + 'keys': 1056 # Must send this first every time before any special action + }, + { + 'ly': 0.0, + 'lx': 0.0, + 'rx': 0.0, + 'ry': 0.0, + 'keys': 257 # Then send the special action command + }], + durations=[1.0, 1.0] + ) + + Safety Guidelines: + - Always validate robot state before sending commands + - Use reasonable duration values (typically 0.1-2.0 seconds) + - Monitor for obstacles using sensor data when available + - If connection is lost, assume robot needs to be stopped + + Unitree Go2 Robot Sensor Information: + The Unitree Go2 robot provides various sensor data. Here are some key topics: + * /utlidar/cloud: Lidar point cloud data + * /camera/rgb/image_raw: RGB camera images + Using these sensors, the robot can measure the distance to objects and perceive its environment. diff --git a/ros_mcp/__init__.py b/ros_mcp/__init__.py new file mode 100644 index 0000000..6a35217 --- /dev/null +++ b/ros_mcp/__init__.py @@ -0,0 +1,10 @@ +"""ROS MCP Package - Modularized ROS-MCP-Server. + +This package provides ROS MCP tools that can be registered with any FastMCP instance. +""" + +from ros_mcp.main import main, mcp +from ros_mcp.tools import register_all_tools +from ros_mcp.utils.websocket import WebSocketManager + +__all__ = ["main", "mcp", "register_all_tools", "WebSocketManager"] diff --git a/ros_mcp/integration.py b/ros_mcp/integration.py new file mode 100644 index 0000000..e77f6ce --- /dev/null +++ b/ros_mcp/integration.py @@ -0,0 +1,55 @@ +"""Integration module for ros-mcp-server with robotmcp_server. + +This module provides the register() function called by submodule_integration.py. +It reads its own configuration from environment variables, keeping ROS-specific +configuration encapsulated within this submodule. +""" + +import logging +import os + +from fastmcp import FastMCP + +from ros_mcp.prompts import register_all_prompts +from ros_mcp.resources import register_all_resources +from ros_mcp.tools import register_all_tools +from ros_mcp.utils.websocket import WebSocketManager + +logger = logging.getLogger(__name__) + +# ROS Bridge defaults (can be overridden via environment variables) +DEFAULT_ROSBRIDGE_IP = "127.0.0.1" +DEFAULT_ROSBRIDGE_PORT = 9090 +DEFAULT_TIMEOUT = 5.0 + + +def register(mcp: FastMCP, **kwargs) -> None: + """Register all ROS MCP tools, resources, and prompts. + + This is the main entry point called by submodule_integration.py. + Configuration is read from environment variables, not passed as parameters. + + Environment variables: + ROSBRIDGE_IP: IP address of rosbridge server (default: 127.0.0.1) + ROSBRIDGE_PORT: Port of rosbridge server (default: 9090) + ROS_DEFAULT_TIMEOUT: Default timeout for ROS operations (default: 5.0) + + Args: + mcp: FastMCP instance to register with + **kwargs: Ignored (for forward compatibility) + """ + rosbridge_ip = os.getenv("ROSBRIDGE_IP", DEFAULT_ROSBRIDGE_IP) + rosbridge_port = int(os.getenv("ROSBRIDGE_PORT", str(DEFAULT_ROSBRIDGE_PORT))) + default_timeout = float(os.getenv("ROS_DEFAULT_TIMEOUT", str(DEFAULT_TIMEOUT))) + + logger.info(f"[ROS_MCP] Initializing with rosbridge at {rosbridge_ip}:{rosbridge_port}") + + # Create WebSocketManager with configuration + ws_manager = WebSocketManager(rosbridge_ip, rosbridge_port, default_timeout=default_timeout) + + # Register all components + register_all_tools(mcp, ws_manager, rosbridge_ip=rosbridge_ip, rosbridge_port=rosbridge_port) + register_all_resources(mcp, ws_manager) + register_all_prompts(mcp) + + logger.info("[ROS_MCP] Registration complete") diff --git a/ros_mcp/main.py b/ros_mcp/main.py new file mode 100644 index 0000000..304fb6c --- /dev/null +++ b/ros_mcp/main.py @@ -0,0 +1,102 @@ +"""ROS MCP Server - MCP instance and main entry point. + +This module provides the FastMCP server instance and main() function. +""" + +import argparse +import sys + +from fastmcp import FastMCP + +from ros_mcp.prompts import register_all_prompts +from ros_mcp.resources import register_all_resources +from ros_mcp.tools import register_all_tools +from ros_mcp.utils.websocket import WebSocketManager + +# ROS bridge connection settings +ROSBRIDGE_IP = "127.0.0.1" # Default is localhost. Replace with your local IP or set using the LLM. +ROSBRIDGE_PORT = ( + 9090 # Rosbridge default is 9090. Replace with your rosbridge port or set using the LLM. +) + +# Initialize MCP server +mcp = FastMCP("ros-mcp-server") + +# Initialize WebSocket manager +ws_manager = WebSocketManager(ROSBRIDGE_IP, ROSBRIDGE_PORT, default_timeout=5.0) + +# Register all tools +register_all_tools(mcp, ws_manager, rosbridge_ip=ROSBRIDGE_IP, rosbridge_port=ROSBRIDGE_PORT) + +# Register all resources +register_all_resources(mcp, ws_manager) + +# Register all prompts +register_all_prompts(mcp) + + +def parse_arguments(): + """Parse command line arguments for MCP server configuration.""" + parser = argparse.ArgumentParser( + description="ROS MCP Server - Connect to ROS robots via MCP protocol", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python -m ros_mcp.main # Use stdio transport (default) + python -m ros_mcp.main --transport http --host 0.0.0.0 --port 9000 + python -m ros_mcp.main --transport streamable-http --host 127.0.0.1 --port 8080 + python server.py # Or use the top-level entry point + """, + ) + + parser.add_argument( + "--transport", + choices=["stdio", "http", "streamable-http"], + default="stdio", + help="MCP transport protocol to use (default: stdio)", + ) + + parser.add_argument( + "--host", + default="127.0.0.1", + help="Host address for HTTP-based transports (default: 127.0.0.1)", + ) + + parser.add_argument( + "--port", + type=int, + default=9000, + help="Port number for HTTP-based transports (default: 9000)", + ) + + return parser.parse_args() + + +def main(): + """Main entry point for the MCP server console script.""" + # Parse command line arguments + args = parse_arguments() + + # Get transport settings + mcp_transport = args.transport.lower() + mcp_host = args.host + mcp_port = args.port + + if mcp_transport == "stdio": + # stdio doesn't need host/port + mcp.run(transport="stdio") + + elif mcp_transport in {"http", "streamable-http"}: + # http and streamable-http both require host/port + print(f"Transport: {mcp_transport} -> http://{mcp_host}:{mcp_port}", file=sys.stderr) + mcp.run(transport=mcp_transport, host=mcp_host, port=mcp_port) + + else: + raise ValueError( + f"Unsupported MCP_TRANSPORT={mcp_transport!r}. " + "Use 'stdio', 'http', or 'streamable-http'." + ) + + +if __name__ == "__main__": + main() diff --git a/ros_mcp/prompts/__init__.py b/ros_mcp/prompts/__init__.py new file mode 100644 index 0000000..373b651 --- /dev/null +++ b/ros_mcp/prompts/__init__.py @@ -0,0 +1,23 @@ +"""Prompts package for ROS MCP Server. + +Functions to register prompts with the MCP server instance. +""" + +from ros_mcp.prompts.test_actions_tools import register_test_actions_tools_prompts +from ros_mcp.prompts.test_connection_tools import register_test_connection_tools_prompts +from ros_mcp.prompts.test_nodes_tools import register_test_nodes_tools_prompts +from ros_mcp.prompts.test_parameters_tools import register_test_parameters_tools_prompts +from ros_mcp.prompts.test_server_tools import register_test_server_tools_prompts +from ros_mcp.prompts.test_services_tools import register_test_services_tools_prompts +from ros_mcp.prompts.test_topics_tools import register_test_topics_tools_prompts + + +def register_all_prompts(mcp): + """Register all prompts with the MCP server instance.""" + register_test_server_tools_prompts(mcp) + register_test_connection_tools_prompts(mcp) + register_test_nodes_tools_prompts(mcp) + register_test_services_tools_prompts(mcp) + register_test_topics_tools_prompts(mcp) + register_test_actions_tools_prompts(mcp) + register_test_parameters_tools_prompts(mcp) diff --git a/ros_mcp/prompts/test_actions_tools.py b/ros_mcp/prompts/test_actions_tools.py new file mode 100644 index 0000000..911faef --- /dev/null +++ b/ros_mcp/prompts/test_actions_tools.py @@ -0,0 +1,448 @@ +"""Test action tools prompts for ROS MCP Server.""" + + +def register_test_actions_tools_prompts(mcp): + """Register test action tools prompts with the MCP server.""" + + @mcp.prompt(name="test-actions-tools") + def test_actions_tools() -> str: + """ + Guide users on how to test and explore the ROS action tools. + + This prompt provides step-by-step instructions for testing action operations, + including getting action lists, action details, sending goals, and monitoring action status. + + Returns: + str: Comprehensive guide for testing action tools + """ + return """# Testing ROS Action Tools - Detailed Guide + +This is a detailed guide for testing action tools. For a quick overview of all ROS MCP Server tools, see `test-server-tools`. + +## When to Use This Guide + +Use this detailed guide when: +- The main action tools from `test-server-tools` are not working +- You need step-by-step instructions for each action tool +- You need troubleshooting help for specific action tool issues +- You want to understand action tool details and advanced usage +- You need to test the actions details resource + +For a quick high-level overview, see `test-server-tools`. + +## Prerequisites + +Before testing action tools, ensure you have: + +1. **Active ROS connection** - Connect to a ROS system first: + ``` + connect_to_robot(ip='127.0.0.1', port=9090) + ``` + +2. **ROS 2 system** - Actions are only available in ROS 2, not ROS 1 + +3. **Running ROS actions** - Make sure you have some actions available in your ROS system. + Common actions include: + - `/turtle1/rotate_absolute` - Rotate turtle to absolute angle (turtlesim) + - `/fibonacci` - Fibonacci action server (if available) + - `/navigate_to_pose` - Navigation action (if navigation stack is running) + +## Action Tools Overview + +The ROS MCP Server provides the following action tools: + +1. **get_actions()** - Get list of all available ROS actions +2. **get_action_details(action)** - Get complete action details including type, goal, result, and feedback structures +3. **get_action_status(action_name)** - Get action status for a specific action name +4. **send_action_goal(action_name, action_type, goal, timeout)** - Send a goal to a ROS action server +5. **cancel_action_goal(action_name, goal_id)** - Cancel a specific action goal + +Additionally, comprehensive information about all actions is available as a resource: +- **ros-mcp://ros-metadata/actions/all** - Get detailed information about all actions (types, status) + +## Step 1: Get List of All Actions + +Start by discovering what actions are available in your ROS system: + +``` +get_actions() +``` + +This will return: +- `actions`: List of all active action names +- `action_count`: Total number of actions + +**Example:** +``` +get_actions() +``` + +**Expected Response:** +```json +{ + "actions": ["/turtle1/rotate_absolute"], + "action_count": 1 +} +``` + +## Step 2: Get Details for a Specific Action + +Get detailed information about a specific action, including its type, goal structure, result structure, and feedback structure: + +``` +get_action_details('/action_name') +``` + +**Examples:** +``` +get_action_details('/turtle1/rotate_absolute') +get_action_details('/fibonacci') +``` + +**Response includes:** +- `action`: The action name +- `action_type`: The action type (e.g., "turtlesim/action/RotateAbsolute") +- `goal`: Goal message structure with fields, field_details, examples, and constants +- `result`: Result message structure with fields, field_details, examples, and constants +- `feedback`: Feedback message structure with fields, field_details, examples, and constants + +**Example Response:** +```json +{ + "action": "/turtle1/rotate_absolute", + "action_type": "turtlesim/action/RotateAbsolute", + "goal": { + "fields": {"theta": "float32"}, + "field_count": 1, + "field_details": { + "theta": { + "type": "float32", + "array_length": -1, + "example": null + } + }, + "message_type": "turtlesim/action/RotateAbsolute_Goal", + "examples": [], + "constants": {} + }, + "result": { + "fields": {}, + "field_count": 0, + "field_details": {}, + "message_type": "turtlesim/action/RotateAbsolute_Result", + "examples": [], + "constants": {} + }, + "feedback": { + "fields": {"remaining": "float32"}, + "field_count": 1, + "field_details": { + "remaining": { + "type": "float32", + "array_length": -1, + "example": null + } + }, + "message_type": "turtlesim/action/RotateAbsolute_Feedback", + "examples": [], + "constants": {} + } +} +``` + +## Step 3: Send an Action Goal + +Send a goal to an action server. This will execute the action and return the result: + +``` +send_action_goal(action_name='/action_name', action_type='package/action/ActionType', goal={'field': value}) +``` + +**Examples:** +``` +# Rotate turtle to 90 degrees (1.57 radians) +send_action_goal( + action_name='/turtle1/rotate_absolute', + action_type='turtlesim/action/RotateAbsolute', + goal={'theta': 1.57} +) + +# Fibonacci action (if available) +send_action_goal( + action_name='/fibonacci', + action_type='action_tutorials_interfaces/action/Fibonacci', + goal={'order': 10}, + timeout=30.0 +) +``` + +**Response includes:** +- `action`: The action name +- `action_type`: The action type +- `success`: Whether the action completed successfully +- `goal_id`: Unique identifier for the goal +- `status`: Final status of the action +- `result`: Result message from the action (if successful) +- `error`: Error message (if failed) + +**Example Response:** +```json +{ + "action": "/turtle1/rotate_absolute", + "action_type": "turtlesim/action/RotateAbsolute", + "success": true, + "goal_id": "goal_1234567890_abcdef12", + "status": 4, + "result": {} +} +``` + +## Step 4: Get Action Status + +Get the current status of an action, including active goals and their status. This is useful after sending a goal to check if it's still executing: + +``` +get_action_status('/action_name') +``` + +**Examples:** +``` +get_action_status('/turtle1/rotate_absolute') +get_action_status('/fibonacci') +``` + +**Response includes:** +- `action_name`: The action name +- `success`: Whether the status query was successful +- `active_goals`: List of active goals with their status +- `goal_count`: Number of active goals +- Each goal includes: + - `goal_id`: Unique identifier for the goal + - `status`: Numeric status code + - `status_text`: Human-readable status (e.g., "STATUS_EXECUTING") + - `timestamp`: When the goal was created + +**Example Response:** +```json +{ + "action_name": "/turtle1/rotate_absolute", + "success": true, + "active_goals": [ + { + "goal_id": "goal_1234567890_abcdef12", + "status": 2, + "status_text": "STATUS_EXECUTING", + "timestamp": "1234567890.123456789" + } + ], + "goal_count": 1, + "note": "Found 1 active goal(s) for action /turtle1/rotate_absolute" +} +``` + +## Step 5: Cancel an Action Goal + +Cancel a running action goal: + +``` +cancel_action_goal(action_name='/action_name', goal_id='goal_id_string') +``` + +**Example:** +``` +cancel_action_goal( + action_name='/turtle1/rotate_absolute', + goal_id='goal_1234567890_abcdef12' +) +``` + +**Response includes:** +- `action`: The action name +- `goal_id`: The goal ID that was cancelled +- `success`: Whether the cancel request was sent successfully +- `note`: Additional information + +**Example Response:** +```json +{ + "action": "/turtle1/rotate_absolute", + "goal_id": "goal_1234567890_abcdef12", + "success": true, + "note": "Cancel request sent successfully. Action may still be executing." +} +``` + +## Step 6: Get All Actions Details (Resource) + +Get comprehensive information about all actions at once using the resource: + +**Resource URI:** `ros-mcp://ros-metadata/actions/all` + +This resource provides: +- Details for every action in the system +- Action types and status for each action +- Connection counts and statistics +- Any errors encountered during inspection + +**How to access:** +The resource can be accessed through the MCP resource interface. It returns a JSON string with comprehensive action information. + +**Testing the resource:** + +1. **Access the resource** through your MCP client: + - Resource URI: `ros-mcp://ros-metadata/actions/all` + - This will return a JSON string with all action information + +2. **Compare with individual tool calls:** + ``` + # Get actions list + get_actions() + + # Get details for each action individually + get_action_details('/turtle1/rotate_absolute') + + # Compare with resource output + # Resource: ros-mcp://ros-metadata/actions/all + ``` + +3. **Use the resource for system overview:** + - The resource is more efficient for getting information about all actions at once + - Useful for understanding the complete system architecture + - Provides action types even when detailed structures aren't available + +**Response includes:** +- `total_actions`: Total number of actions +- `actions`: Dictionary with details for each action + - Each action entry contains: + - `type`: The action type (e.g., "turtlesim/action/RotateAbsolute") + - `status`: Status of the action ("available" or "type_unknown") +- `action_errors`: List of any errors encountered (if any) + +**Example Response:** +```json +{ + "total_actions": 1, + "actions": { + "/turtle1/rotate_absolute": { + "type": "turtlesim/action/RotateAbsolute", + "status": "available" + } + }, + "action_errors": [] +} +``` + +**When to use the resource vs individual tools:** +- **Use the resource** when you need a quick overview of all actions and their types +- **Use `get_action_details()`** when you need detailed goal/result/feedback structures for a specific action +- **Use `get_actions()`** when you just need a simple list of action names + +## Action Naming Convention + +ROS actions use the format: `/action_name` + +- Action names always start with `/` +- Action names are case-sensitive +- Common actions include: + - `/turtle1/rotate_absolute` - Turtlesim rotate action + - `/fibonacci` - Fibonacci action server + - `/navigate_to_pose` - Navigation action + +## Action Status Codes + +Action status codes indicate the current state of a goal: + +- `0`: STATUS_UNKNOWN - Unknown status +- `1`: STATUS_ACCEPTED - Goal was accepted +- `2`: STATUS_EXECUTING - Goal is currently executing +- `3`: STATUS_CANCELING - Goal is being cancelled +- `4`: STATUS_SUCCEEDED - Goal completed successfully +- `5`: STATUS_CANCELED - Goal was cancelled +- `6`: STATUS_ABORTED - Goal execution was aborted + +- [ ] Test action execution with different goals +- [ ] Test action cancellation + +## Troubleshooting + +### "No actions found" or Empty Action List + +**Problem:** `get_actions()` returns no actions or a warning + +**Solutions:** +- Verify ROS connection: `connect_to_robot()` +- Check if ROS 2 system is running: `detect_ros_version()` +- Ensure actions are actually available in your ROS system +- Actions are ROS 2 only - they won't appear in ROS 1 systems +- Try launching some action servers: `ros2 run` with action servers + +### "Action type not found" Error + +**Problem:** `get_action_details()` returns "Action type not found" + +**Solutions:** +- Verify the action name is correct (case-sensitive) +- Check if the action is actually running: `get_actions()` +- Ensure action name starts with `/` +- Action might have stopped running - check again with `get_actions()` +- Some actions may not expose type information through rosapi + +### "Service call failed" Error + +**Problem:** Service call to get action information fails + +**Solutions:** +- Verify rosbridge connection is active +- Check if `/rosapi/action_servers` or other action services are available +- Try reconnecting: `connect_to_robot()` +- Check ROS system is responsive +- Some action services may not be available in all rosbridge versions + +### "Action details not found" Error + +**Problem:** Action type found but detailed structures are not available + +**Solutions:** +- This is normal for some rosbridge/rosapi versions +- Action detail services (`/rosapi/action_*_details`) are not part of standard rosapi +- The action type will still be returned, but goal/result/feedback structures may be empty +- Consider subscribing to action topics directly for live message inspection + +### Action Goal Timeout + +**Problem:** `send_action_goal()` times out + +**Solutions:** +- Increase the timeout parameter: `send_action_goal(..., timeout=30.0)` +- Check if the action server is actually running +- Verify the goal structure matches what the action expects +- Check action status: `get_action_status('/action_name')` +- Some actions may take longer to complete + +### Action Goal Fails + +**Problem:** `send_action_goal()` returns success=False + +**Solutions:** +- Verify the action name is correct +- Verify the action type is correct +- Check the goal structure matches the action's goal message type +- Use `get_action_details()` to see the expected goal structure +- Check if the action server is running and accepting goals + +## Tips + +- **Start with `get_actions()`** - Always start by discovering what actions are available +- **Use `get_action_details()` for specific actions** - More efficient than getting all actions details +- **Use the resource `ros-mcp://ros-metadata/actions/all` for complete overview** - Provides comprehensive information about all actions +- **Action names are case-sensitive** - `/Turtle1/RotateAbsolute` is different from `/turtle1/rotate_absolute` +- **Actions can be added/removed dynamically** - Re-run `get_actions()` if you expect changes +- **Actions are ROS 2 only** - They won't work with ROS 1 systems +- **Use `get_action_status()` after sending a goal** - Check if actions are still executing after sending a goal +- **Goal IDs are unique** - Save the goal_id from `send_action_goal()` if you need to cancel it later +- **Timeout parameter is optional** - Default is 10 seconds, but you can specify longer for slow actions + +## Related Guides + +- **`test-server-tools`** - High-level overview of all ROS MCP Server tools +""" diff --git a/ros_mcp/prompts/test_connection_tools.py b/ros_mcp/prompts/test_connection_tools.py new file mode 100644 index 0000000..3dd2add --- /dev/null +++ b/ros_mcp/prompts/test_connection_tools.py @@ -0,0 +1,241 @@ +"""Test connection tools prompts for ROS MCP Server.""" + + +def register_test_connection_tools_prompts(mcp): + """Register test connection tools prompts with the MCP server.""" + + @mcp.prompt(name="test-connection-tools") + def test_connection_tools() -> str: + """ + Guide users on how to test and explore the ROS connection tools. + + This prompt provides step-by-step instructions for testing connection operations, + including connecting to robots, pinging robots, and detecting ROS versions. + + Returns: + str: Comprehensive guide for testing connection tools + """ + return """# Testing ROS Connection Tools - Detailed Guide + +This is a detailed guide for testing connection tools. For a quick overview of all ROS MCP Server tools, see `test-server-tools`. + +## When to Use This Guide + +Use this detailed guide when: +- The main connection tools from `test-server-tools` are not working +- You need step-by-step instructions for each connection tool +- You need troubleshooting help for specific connection issues +- You want to understand connection tool details and advanced usage +- You're having network or connectivity problems + +For a quick high-level overview, see `test-server-tools`. + +## Prerequisites + +Before testing connection tools, ensure you have: + +1. **Network access** to the robot or ROS system +2. **Rosbridge server** running on the target system (typically on port 9090) +3. **IP address and port** of the rosbridge server + +## Connection Tools Overview + +The ROS MCP Server provides the following connection tools: + +1. **connect_to_robot** - Connect to a robot by setting IP/port and test connectivity +2. **ping_robots** - Ping one or more robot IP addresses and check if specific ports are open +3. **detect_ros_version** - Detect the ROS version and distribution via rosbridge + +## Step 1: Ping Robots (Test Connectivity) + +Before connecting, you can test if one or more robots are reachable and if the rosbridge ports are open: + +``` +ping_robots(targets=[{'ip': '192.168.1.100', 'port': 9090}]) +``` + +This will: +- Ping the IP address(es) to check if the host(s) are reachable +- Check if the specified port(s) are open +- Return connectivity status for each target + +**Example (single robot):** +``` +ping_robots(targets=[{'ip': '127.0.0.1', 'port': 9090}]) +``` + +**Example (multiple robots):** +``` +ping_robots(targets=[ + {'ip': '192.168.1.100', 'port': 9090}, + {'ip': '192.168.1.101', 'port': 9090}, + {'ip': '192.168.1.102', 'port': 9091} +], ping_timeout=2.0, port_timeout=2.0) +``` + +**Parameters:** +- `targets` (required): List of target dictionaries, each containing: + - `ip` (str): The IP address of the robot/rosbridge server + - `port` (int): The port number (typically 9090 for rosbridge) +- `ping_timeout` (optional): Timeout for ping in seconds (default: 2.0) +- `port_timeout` (optional): Timeout for port check in seconds (default: 2.0) + +**Response:** +The response includes a `results` list with individual results for each target: +- Each result contains: + - `ip`: The IP address that was pinged + - `port`: The port that was checked + - `ping`: Ping status with success, error, and response_time_ms + - `port_check`: Port check status with open status and error + - `overall_status`: Overall connectivity status + +**Note:** A successful ping to the IP but not the port can indicate that rosbridge is not running. + +## Step 2: Connect to a Robot + +Connect to a robot by setting the IP and port for the WebSocket connection: + +``` +connect_to_robot(ip='192.168.1.100', port=9090) +``` + +This will: +- Set the WebSocket connection IP and port +- Test connectivity (ping and port check) +- Return connection status + +**Example:** +``` +connect_to_robot(ip='127.0.0.1', port=9090) +connect_to_robot(ip='192.168.1.50', port=9090, ping_timeout=3.0, port_timeout=3.0) +``` + +**Parameters:** +- `ip` (optional): The IP address (default: 127.0.0.1) +- `port` (optional): The port number (default: 9090) +- `ping_timeout` (optional): Timeout for ping in seconds (default: 2.0) +- `port_timeout` (optional): Timeout for port check in seconds (default: 2.0) + +**Response:** +The response includes: +- `message`: Confirmation message with IP and port +- `connectivity_test`: Results of ping and port checks + +## Step 3: Detect ROS Version + +After connecting, detect the ROS version and distribution: + +``` +detect_ros_version() +``` + +This will: +- Attempt to detect ROS 1 or ROS 2 +- Return the version number and distribution name +- Work via rosbridge WebSocket connection + +**Example:** +``` +detect_ros_version() +``` + +**Response:** +The response includes: +- `version`: ROS version (1 or 2) +- `distro`: Distribution name (e.g., "humble", "noetic", "melodic") + +**Note:** This tool requires an active rosbridge connection. Make sure you've connected first using `connect_to_robot()`. + +## Common Connection Scenarios + +### Scenario 1: Connect to Local ROS System + +For testing with a local ROS system (rosbridge running on the same machine): + +``` +connect_to_robot(ip='127.0.0.1', port=9090) +detect_ros_version() +``` + +### Scenario 2: Connect to Remote Robot + +For connecting to a robot on the network: + +``` +# First, test connectivity +ping_robots(targets=[{'ip': '192.168.1.100', 'port': 9090}]) + +# If successful, connect +connect_to_robot(ip='192.168.1.100', port=9090) + +# Verify ROS version +detect_ros_version() +``` + +### Scenario 3: Connect with Custom Port + +If rosbridge is running on a non-standard port: + +``` +connect_to_robot(ip='192.168.1.100', port=9091) +ping_robots(targets=[{'ip': '192.168.1.100', 'port': 9091}]) +``` + + +## Troubleshooting + +### Connection Timeout Errors + +**Problem:** `ping_robots()` or `connect_to_robot()` times out + +**Solutions:** +- Verify the IP address is correct +- Check if the robot is on the same network +- Ensure rosbridge is running on the target system +- Try increasing timeout values: `ping_timeout=5.0, port_timeout=5.0` +- Check firewall settings that might block the port + +### Port Not Open + +**Problem:** Ping succeeds but port check fails + +**Solutions:** +- Verify rosbridge is running: `rosrun rosbridge_server rosbridge_websocket` (ROS 1) or `ros2 run rosbridge_server rosbridge_websocket` (ROS 2) +- Check if rosbridge is listening on the correct port +- Verify no firewall is blocking the port +- Check rosbridge configuration for port settings + +### Cannot Detect ROS Version + +**Problem:** `detect_ros_version()` fails or returns error + +**Solutions:** +- Ensure you've connected first using `connect_to_robot()` +- Verify rosbridge connection is active +- Check that rosbridge is properly configured +- Try reconnecting: `connect_to_robot()` again + +### IP Address Not Reachable + +**Problem:** Ping fails for the IP address + +**Solutions:** +- Verify the IP address is correct +- Check network connectivity (can you ping from terminal?) +- Ensure the robot is powered on and connected to network +- Check network configuration (subnet, gateway, etc.) +- Try using hostname instead of IP if DNS is configured + +## Tips + +- Always use `ping_robots()` first to test connectivity before connecting +- The default port for rosbridge is 9090, but it can be configured differently +- Connection settings persist until you call `connect_to_robot()` again +- Use `detect_ros_version()` after connecting to verify the connection works +- For local testing, use `127.0.0.1` or `localhost` +- For remote robots, ensure both systems are on the same network or VPN + +## Related Guides + +- **`test-server-tools`** - High-level overview of all ROS MCP Server tools +""" diff --git a/ros_mcp/prompts/test_nodes_tools.py b/ros_mcp/prompts/test_nodes_tools.py new file mode 100644 index 0000000..11e7406 --- /dev/null +++ b/ros_mcp/prompts/test_nodes_tools.py @@ -0,0 +1,235 @@ +"""Test node tools prompts for ROS MCP Server.""" + + +def register_test_nodes_tools_prompts(mcp): + """Register test node tools prompts with the MCP server.""" + + @mcp.prompt(name="test-nodes-tools") + def test_nodes_tools() -> str: + """ + Guide users on how to test and explore the ROS node tools. + + This prompt provides step-by-step instructions for testing node operations, + including getting node lists, node details, and comprehensive node inspection. + + Returns: + str: Comprehensive guide for testing node tools + """ + return """# Testing ROS Node Tools - Detailed Guide + +This is a detailed guide for testing node tools. For a quick overview of all ROS MCP Server tools, see `test-server-tools`. + +## When to Use This Guide + +Use this detailed guide when: +- The main node tools from `test-server-tools` are not working +- You need step-by-step instructions for each node tool +- You need troubleshooting help for specific node tool issues +- You want to understand node tool details and advanced usage +- You need to test node resources or advanced features + +For a quick high-level overview, see `test-server-tools`. + +## Prerequisites + +Before testing node tools, ensure you have: + +1. **Active ROS connection** - Connect to a ROS system first: + ``` + connect_to_robot(ip='127.0.0.1', port=9090) + ``` + +2. **Running ROS nodes** - Make sure you have some nodes running in your ROS system. + Common nodes include: + - `/turtlesim` - Turtlesim simulator + - `/rosbridge_websocket` - Rosbridge server + - `/rosapi` - ROS API service provider + - `/cam2image` - Camera image publisher + +## Node Tools Overview + +The ROS MCP Server provides the following node tools: + +1. **get_nodes()** - Get list of all currently running ROS nodes +2. **get_node_details(node)** - Get detailed information about a specific node + +Additionally, comprehensive information about all nodes is available as a resource: +- **ros-mcp://ros-metadata/nodes/all** - Get detailed information about all nodes (publishers, subscribers, services) + +## Step 1: Get List of All Nodes + +Start by discovering what nodes are running in your ROS system: + +``` +get_nodes() +``` + +This will return: +- `nodes`: List of all active node names +- `node_count`: Total number of nodes + +**Example:** +``` +get_nodes() +``` + +**Expected Response:** +```json +{ + "nodes": ["/turtlesim", "/rosbridge_websocket", "/rosapi"], + "node_count": 3 +} +``` + +## Step 2: Get Details for a Specific Node + +Get detailed information about a specific node, including what it publishes, subscribes to, and what services it provides: + +``` +get_node_details('/node_name') +``` + +**Examples:** +``` +get_node_details('/turtlesim') +get_node_details('/rosapi') +get_node_details('/cam2image') +``` + +**Response includes:** +- `node`: The node name +- `publishers`: List of topics this node publishes to +- `subscribers`: List of topics this node subscribes to +- `services`: List of services this node provides +- `publisher_count`: Number of publishers +- `subscriber_count`: Number of subscribers +- `service_count`: Number of services + +**Example Response:** +```json +{ + "node": "/turtlesim", + "publishers": ["/turtle1/pose", "/turtle1/color_sensor"], + "subscribers": ["/turtle1/cmd_vel"], + "services": ["/turtle1/teleport_absolute", "/turtle1/teleport_relative"], + "publisher_count": 2, + "subscriber_count": 1, + "service_count": 2 +} +``` + +## Step 3: Get All Nodes Details (Resource) + +Get comprehensive information about all nodes at once using the resource: + +**Resource URI:** `ros-mcp://ros-metadata/nodes/all` + +This resource provides: +- Details for every node in the system +- Publishers, subscribers, and services for each node +- Connection counts and statistics +- Any errors encountered during inspection + +**How to access:** +The resource can be accessed through the MCP resource interface. It returns a JSON string with comprehensive node information. + +**Response includes:** +- `total_nodes`: Total number of nodes +- `nodes`: Dictionary with details for each node +- `node_errors`: List of any errors encountered (if any) + +**Example Response:** +```json +{ + "total_nodes": 3, + "nodes": { + "/turtlesim": { + "publishers": ["/turtle1/pose", "/turtle1/color_sensor"], + "subscribers": ["/turtle1/cmd_vel"], + "services": ["/turtle1/teleport_absolute"], + "publisher_count": 2, + "subscriber_count": 1, + "service_count": 1 + }, + "/rosapi": { + "publishers": [], + "subscribers": [], + "services": ["/rosapi/topics", "/rosapi/services"], + "publisher_count": 0, + "subscriber_count": 0, + "service_count": 2 + } + }, + "node_errors": [] +} +``` + +## Node Naming Convention + +ROS nodes use the format: `/node_name` + +- Node names always start with `/` +- Node names are case-sensitive +- Common nodes include: + - `/turtlesim` - Turtlesim simulator + - `/rosbridge_websocket` - Rosbridge server + - `/rosapi` - ROS API service provider + - `/rosapi_params` - ROS API parameters service (ROS 2) + + +## Troubleshooting + +### "No nodes found" or Empty Node List + +**Problem:** `get_nodes()` returns no nodes or a warning + +**Solutions:** +- Verify ROS connection: `connect_to_robot()` +- Check if ROS system is running: `detect_ros_version()` +- Ensure nodes are actually running in your ROS system +- Try launching some nodes: `rosrun` (ROS 1) or `ros2 run` (ROS 2) + +### "Node not found" Error + +**Problem:** `get_node_details()` returns "Node not found" + +**Solutions:** +- Verify the node name is correct (case-sensitive) +- Check if the node is actually running: `get_nodes()` +- Ensure node name starts with `/` +- Node might have stopped running - check again with `get_nodes()` + +### "Service call failed" Error + +**Problem:** Service call to get node information fails + +**Solutions:** +- Verify rosbridge connection is active +- Check if `/rosapi/nodes` or `/rosapi/node_details` services are available +- Try reconnecting: `connect_to_robot()` +- Check ROS system is responsive + +### Empty Publishers/Subscribers/Services + +**Problem:** Node exists but has no publishers, subscribers, or services + +**Solutions:** +- This is normal for some nodes (e.g., service-only nodes) +- Node might be in initialization phase +- Check if node is fully started +- Some nodes only provide services and don't publish/subscribe + +## Tips + +- **Start with `get_nodes()`** - Always start by discovering what nodes are available +- **Use `get_node_details()` for specific nodes** - More efficient than getting all nodes details +- **Use the resource `ros-mcp://ros-metadata/nodes/all` for complete overview** - Provides comprehensive information about all nodes +- **Node names are case-sensitive** - `/Turtlesim` is different from `/turtlesim` +- **Nodes can be added/removed dynamically** - Re-run `get_nodes()` if you expect changes +- **Combine with topic tools** - Use node details to understand topic connections +- **Combine with service tools** - Use node details to discover available services + +## Related Guides + +- **`test-server-tools`** - High-level overview of all ROS MCP Server tools +""" diff --git a/ros_mcp/prompts/test_parameters_tools.py b/ros_mcp/prompts/test_parameters_tools.py new file mode 100644 index 0000000..f18e489 --- /dev/null +++ b/ros_mcp/prompts/test_parameters_tools.py @@ -0,0 +1,218 @@ +"""Test parameter tools prompts for ROS MCP Server.""" + + +def register_test_parameters_tools_prompts(mcp): + """Register test parameter tools prompts with the MCP server.""" + + @mcp.prompt(name="test-parameters-tools") + def test_parameters_tools() -> str: + """ + Guide users on how to test and explore the ROS parameter tools. + + This prompt provides step-by-step instructions for testing parameter operations, + including getting, setting, checking, and deleting parameters in ROS 2. + + Returns: + str: Comprehensive guide for testing parameter tools + """ + return """# Testing ROS Parameter Tools - Detailed Guide + +This is a detailed guide for testing parameter tools. For a quick overview of all ROS MCP Server tools, see `test-server-tools`. + +**Note: Parameter tools work only with ROS 2.** + +## When to Use This Guide + +Use this detailed guide when: +- The main parameter tools from `test-server-tools` are not working +- You need step-by-step instructions for each parameter tool +- You need troubleshooting help for specific parameter tool issues +- You want to understand parameter tool details and advanced usage +- You need to test parameter resources or advanced features + +For a quick high-level overview, see `test-server-tools`. + +## Prerequisites + +Before testing parameter tools, ensure you have a working ROS 2 setup: + +1. **Verify ROS 2 connection:** + ``` + detect_ros_version() + ``` + This should return ROS 2 (e.g., "humble", "foxy", "iron"). Parameter tools only work with ROS 2, not ROS 1. + +2. **Check available nodes:** + ``` + get_nodes() + ``` + This lists all running nodes. You'll need node names to get their parameters. Common nodes include: + - `/turtlesim` - Turtlesim simulator + - `/cam2image` - Camera image publisher + - `/rosbridge_websocket` - Rosbridge server + +3. **Verify node services:** + Each ROS 2 node should have a `/list_parameters` service. You can check if a service exists: + ``` + get_services() + ``` + Look for services like `/turtlesim/list_parameters` or `/cam2image/list_parameters`. + +4. **Test basic connectivity:** + If you can successfully call `get_nodes()` and `get_services()`, your connection is working and ready for parameter operations. +## Step 1: Get Parameters for a Node + +Start by listing all available parameters for a specific node: + +``` +get_parameters('cam2image') +get_parameters('/turtlesim') +``` + +This will return a list of all parameter names for the specified node. The node name can be provided with or without a leading slash. The function uses the node-specific `/list_parameters` service which is more reliable than the global parameter service. + +## Step 2: Check if a Parameter Exists + +Before getting or setting a parameter, you can check if it exists: + +``` +has_parameter('/node_name:parameter_name') +``` + +Example: +``` +has_parameter('/turtlesim:background_r') +``` + +## Step 3: Get a Single Parameter Value + +Retrieve the value of a specific parameter: + +``` +get_parameter('/node_name:parameter_name') +``` + +Examples: +``` +get_parameter('/turtlesim:background_r') +get_parameter('/turtlesim:background_g') +get_parameter('/turtlesim:background_b') +``` + +## Step 4: Get Detailed Parameter Information + +Get comprehensive information about a parameter including its type and metadata: + +``` +get_parameter_details('/node_name:parameter_name') +``` + +Example: +``` +get_parameter_details('/turtlesim:background_r') +``` + +This returns: +- Parameter name +- Current value +- Parameter type (int, string, bool, float, etc.) +- Whether it exists +- Description (if available) +- Node name and parameter name (parsed) + +## Step 5: Set a Parameter Value + +Modify a parameter value: + +``` +set_parameter('/node_name:parameter_name', 'value') +``` + +Examples: +``` +set_parameter('/turtlesim:background_r', '255') +set_parameter('/turtlesim:background_g', '0') +set_parameter('/turtlesim:background_b', '0') +``` + +**Note:** Parameter values should be passed as strings, even for numeric types. + +## Step 6: Get Parameters for Multiple Nodes + +To get parameters for multiple nodes, call `get_parameters()` for each node: + +``` +get_parameters('cam2image') +get_parameters('turtlesim') +get_parameters('rosbridge_websocket') +``` + +Each call returns parameters formatted with the node prefix (e.g., `/cam2image:parameter_name`). + +## Step 7: Delete a Parameter + +Remove a parameter from the system: + +``` +delete_parameter('/node_name:parameter_name') +``` + +Example: +``` +delete_parameter('/my_node:temp_parameter') +``` + +**Warning:** Be careful when deleting parameters as this may affect node behavior. + +## Parameter Naming Convention + +ROS 2 parameters use the format: `/node_name:parameter_name` + +- The node name comes before the colon +- The parameter name comes after the colon +- Both parts are required + +Examples: +- `/turtlesim:background_r` - parameter `background_r` in node `turtlesim` +- `/cam2image:use_sim_time` - parameter `use_sim_time` in node `cam2image` + +## Common Parameter Types + +Parameters can be of various types: +- **Integer**: `'42'`, `'0'`, `'-10'` +- **Float**: `'3.14'`, `'0.5'`, `'-1.0'` +- **Boolean**: `'true'`, `'false'` +- **String**: `'hello'`, `'world'` +- **Arrays**: `'[1, 2, 3]'` (passed as string representation) + + +## Troubleshooting + +### "Service call failed" or timeout errors +- Ensure ROS 2 is running and the target node is running +- Check that you're connected to the correct ROS system +- Verify the node name is correct: `get_nodes()` to see available nodes +- The node must have a `/list_parameters` service (standard for ROS 2 nodes) + +### "Parameter does not exist" errors +- Verify the parameter name format: `/node_name:parameter_name` +- Check that the node is running: `get_nodes()` +- Use `get_parameters('node_name')` to see all available parameters for that node + +### Parameter value format issues +- Always pass parameter values as strings +- For boolean values, use `'true'` or `'false'` (lowercase strings) +- For arrays, pass as string representation: `'[1, 2, 3]'` + +## Tips + +- Use `get_parameters()` first to discover available parameters +- `get_parameter_details()` provides the most comprehensive information +- `inspect_all_parameters()` is useful for getting a complete snapshot but may be slow +- Parameter operations are ROS 2 only - they won't work with ROS 1 +- Some parameters may be read-only and cannot be modified + +## Related Guides + +- **`test-server-tools`** - High-level overview of all ROS MCP Server tools +""" diff --git a/ros_mcp/prompts/test_server_tools.py b/ros_mcp/prompts/test_server_tools.py new file mode 100644 index 0000000..af69295 --- /dev/null +++ b/ros_mcp/prompts/test_server_tools.py @@ -0,0 +1,162 @@ +"""Test server tools prompts for ROS MCP Server.""" + + +def register_test_server_tools_prompts(mcp): + """Register test server tools prompts with the MCP server.""" + + @mcp.prompt(name="test-server-tools") + def test_server_tools() -> str: + """ + Guide users on how to test and explore the ROS MCP server tools. + + This prompt provides step-by-step instructions for testing the server, + including how to access capabilities information, test tools, and verify connections. + + Returns: + str: Comprehensive guide for testing server tools + """ + return """# Testing ROS MCP Server - Quick Start Guide + +This is a high-level overview guide for testing the ROS MCP Server. For detailed testing instructions and troubleshooting, see the category-specific guides listed at the end. + +## Prerequisites + +Before testing, ensure you have: +1. **Turtlesim running**: `ros2 run turtlesim turtlesim_node` (ROS 2) or `rosrun turtlesim turtlesim_node` (ROS 1) +2. **Rosbridge running**: `ros2 run rosbridge_server rosbridge_websocket` (ROS 2) or `rosrun rosbridge_server rosbridge_websocket` (ROS 1) + +## Quick Test Workflow + +### 1. Connect and Discover + +```python +# Connect +connect_to_robot(ip='127.0.0.1', port=9090) +detect_ros_version() + +# Discover what's available +get_topics() # Lists all topics +get_services() # Lists all services +get_nodes() # Lists all nodes +get_actions() # Lists all actions (ROS 2 only) +``` + +### 2. Test Main Tools by Category + +**Topics** - Subscribe and publish: +```python +subscribe_once(topic='/turtle1/pose', msg_type='turtlesim/msg/Pose') +publish_once(topic='/turtle1/cmd_vel', msg_type='geometry_msgs/msg/Twist', + msg={'linear': {'x': 2.0, 'y': 0.0, 'z': 0.0}}) +``` + +**Services** - Call services: +```python +call_service(service_name='/turtle1/teleport_absolute', + service_type='turtlesim/srv/TeleportAbsolute', + request={'x': 5.5, 'y': 5.5, 'theta': 0.0}) +``` + +**Nodes** - Inspect nodes: +```python +get_node_details('/turtlesim') +``` + +**Parameters** (ROS 2 only) - Get and set parameters: +```python +get_parameters('turtlesim') +set_parameter('/turtlesim:background_r', '255') +``` + +**Actions** (ROS 2 only) - Send goals: +```python +send_action_goal(action_name='/turtle1/rotate_absolute', + action_type='turtlesim/action/RotateAbsolute', + goal={'theta': 1.57}) +``` + +## Main Tools by Category + +### Connection Tools +- `connect_to_robot()` - Connect to ROS system +- `ping_robots()` - Test connectivity for one or more robots +- `detect_ros_version()` - Detect ROS version + +### Discovery Tools +- `get_topics()` - List all topics +- `get_services()` - List all services +- `get_nodes()` - List all nodes +- `get_actions()` - List all actions (ROS 2) + +### Topic Tools +- `get_topic_details()` - Get topic information +- `subscribe_once()` - Subscribe and get one message +- `publish_once()` - Publish a message + +### Service Tools +- `get_service_details()` - Get service information +- `call_service()` - Call a service + +### Node Tools +- `get_node_details()` - Get node information + +### Parameter Tools (ROS 2) +- `get_parameters()` - List parameters for a node +- `get_parameter()` - Get a parameter value +- `set_parameter()` - Set a parameter value + +### Action Tools (ROS 2) +- `get_action_details()` - Get action information +- `send_action_goal()` - Send an action goal +- `get_action_status()` - Get action status + +## Resources + +Access comprehensive information via resources: +- `ros-mcp://ros-metadata/all` - All ROS metadata +- `ros-mcp://ros-metadata/topics/all` - All topics details +- `ros-mcp://ros-metadata/services/all` - All services details +- `ros-mcp://ros-metadata/nodes/all` - All nodes details +- `ros-mcp://ros-metadata/actions/all` - All actions details (ROS 2) +- `ros-mcp://ros-metadata/parameters/all` - All parameters details (ROS 2) + +## Quick Testing Checklist + +- [ ] Connect: `connect_to_robot()`, `detect_ros_version()` +- [ ] Discover: `get_topics()`, `get_services()`, `get_nodes()`, `get_actions()` +- [ ] Topics: `subscribe_once()`, `publish_once()` +- [ ] Services: `call_service()` +- [ ] Nodes: `get_node_details()` +- [ ] Parameters: `get_parameters()`, `set_parameter()` (ROS 2) +- [ ] Actions: `send_action_goal()`, `get_action_status()` (ROS 2) + +## If Something Fails + +If the main tools above don't work, refer to the detailed category-specific guides for troubleshooting: + +- **Connection issues?** โ†’ See `test-connection-tools` for detailed connection troubleshooting +- **Topic tools not working?** โ†’ See `test-topics-tools` for detailed topic testing and troubleshooting +- **Service tools not working?** โ†’ See `test-services-tools` for detailed service testing and troubleshooting +- **Node tools not working?** โ†’ See `test-nodes-tools` for detailed node testing and troubleshooting +- **Parameter tools not working?** โ†’ See `test-parameters-tools` for detailed parameter testing and troubleshooting (ROS 2) +- **Action tools not working?** โ†’ See `test-actions-tools` for detailed action testing and troubleshooting (ROS 2) + +## Tips + +- Most tools work with both ROS 1 and ROS 2 +- Parameters and Actions are ROS 2 only +- Turtlesim is a great example for testing all tool categories +- Use resources for comprehensive system overview +- Individual test guides provide detailed troubleshooting steps + +## Category-Specific Test Guides + +For detailed testing instructions, troubleshooting, and advanced usage: + +- **`test-connection-tools`** - Detailed connection tool testing +- **`test-topics-tools`** - Detailed topic tool testing +- **`test-services-tools`** - Detailed service tool testing +- **`test-nodes-tools`** - Detailed node tool testing +- **`test-parameters-tools`** - Detailed parameter tool testing (ROS 2) +- **`test-actions-tools`** - Detailed action tool testing (ROS 2) +""" diff --git a/ros_mcp/prompts/test_services_tools.py b/ros_mcp/prompts/test_services_tools.py new file mode 100644 index 0000000..405c1ea --- /dev/null +++ b/ros_mcp/prompts/test_services_tools.py @@ -0,0 +1,344 @@ +"""Test service tools prompts for ROS MCP Server.""" + + +def register_test_services_tools_prompts(mcp): + """Register test service tools prompts with the MCP server.""" + + @mcp.prompt(name="test-services-tools") + def test_services_tools() -> str: + """ + Guide users on how to test and explore the ROS service tools. + + This prompt provides step-by-step instructions for testing service operations, + including getting service lists, service types, service details, and calling services. + + Returns: + str: Comprehensive guide for testing service tools + """ + return """# Testing ROS Service Tools - Detailed Guide + +This is a detailed guide for testing service tools. For a quick overview of all ROS MCP Server tools, see `test-server-tools`. + +## When to Use This Guide + +Use this detailed guide when: +- The main service tools from `test-server-tools` are not working +- You need step-by-step instructions for each service tool +- You need troubleshooting help for specific service tool issues +- You want to understand service tool details and advanced usage +- You need to test service resources or advanced features + +For a quick high-level overview, see `test-server-tools`. + +## Prerequisites + +Before testing service tools, ensure you have: + +1. **Active ROS connection** - Connect to a ROS system first: + ``` + connect_to_robot(ip='127.0.0.1', port=9090) + ``` + +2. **Running ROS services** - Make sure you have some services available in your ROS system. + Common services include: + - `/rosapi/topics` - Get list of topics + - `/rosapi/services` - Get list of services + - `/rosapi/nodes` - Get list of nodes + - `/clear` - Clear turtlesim screen (if turtlesim is running) + - `/reset` - Reset turtlesim (if turtlesim is running) + +## Service Tools Overview + +The ROS MCP Server provides the following service tools: + +1. **get_services()** - Get list of all available ROS services +2. **get_service_type(service)** - Get the service type for a specific service +3. **get_service_details(service)** - Get complete service details including request/response structures and provider nodes +4. **call_service(service_name, service_type, request, timeout)** - Call a ROS service with specified request data + +Additionally, comprehensive information about all services is available as a resource: +- **ros-mcp://ros-metadata/services/all** - Get detailed information about all services (types, providers) + +## Step 1: Get List of All Services + +Start by discovering what services are available in your ROS system: + +``` +get_services() +``` + +This will return: +- `services`: List of all available service names +- `service_count`: Total number of services + +**Example:** +``` +get_services() +``` + +**Expected Response:** +```json +{ + "services": ["/rosapi/topics", "/rosapi/services", "/rosapi/nodes", "/clear"], + "service_count": 4 +} +``` + +## Step 2: Get Service Type + +Get the type of a specific service: + +``` +get_service_type('/service_name') +``` + +**Examples:** +``` +get_service_type('/rosapi/topics') +get_service_type('/clear') +get_service_type('/rosapi/services') +``` + +**Response includes:** +- `service`: The service name +- `type`: The service type (e.g., 'rosapi/Topics', 'std_srvs/Empty') + +**Example Response:** +```json +{ + "service": "/rosapi/topics", + "type": "rosapi/Topics" +} +``` + +## Step 3: Get Service Details + +Get complete information about a service, including request/response structures and provider nodes: + +``` +get_service_details('/service_name') +``` + +**Examples:** +``` +get_service_details('/rosapi/topics') +get_service_details('/clear') +get_service_details('/turtle1/teleport_absolute') +``` + +**Response includes:** +- `service`: The service name +- `type`: The service type +- `request`: Request structure with fields and types +- `response`: Response structure with fields and types +- `providers`: List of nodes that provide this service +- `provider_count`: Number of provider nodes +- `note`: Important information about field name formatting + +**Example Response:** +```json +{ + "service": "/rosapi/topics", + "type": "rosapi_msgs/srv/Topics", + "request": { + "fields": {}, + "field_count": 0 + }, + "response": { + "fields": { + "topics": "string", + "types": "string" + }, + "field_count": 2 + }, + "providers": ["/rosapi"], + "provider_count": 1, + "note": "Field names shown above are formatted for rosbridge (leading underscores removed). Use these exact field names when calling call_service()." +} +``` + +## Step 4: Get All Services Details (Resource) + +Get comprehensive information about all services at once using the resource: + +**Resource URI:** `ros-mcp://ros-metadata/services/all` + +**Note:** This may take time to execute when there are a large number of services since it queries each one by one. + +**How to access:** +The resource can be accessed through the MCP resource interface. It returns a JSON string with comprehensive service information. + +**Response includes:** +- `total_services`: Total number of services +- `services`: Dictionary with details for each service +- `service_errors`: List of any errors encountered (if any) + +**Example Response:** +```json +{ + "total_services": 3, + "services": { + "/rosapi/topics": { + "type": "rosapi/Topics", + "providers": ["/rosapi"], + "provider_count": 1 + }, + "/clear": { + "type": "std_srvs/Empty", + "providers": ["/turtlesim"], + "provider_count": 1 + } + }, + "service_errors": [] +} +``` + +## Step 6: Call a Service + +Call a ROS service with specified request data: + +``` +call_service(service_name, service_type, request, timeout) +``` + +**Parameters:** +- `service_name` (str): The service name (e.g., '/rosapi/topics') +- `service_type` (str): The service type (e.g., 'rosapi/Topics') +- `request` (dict): Service request data as a dictionary +- `timeout` (float, optional): Timeout in seconds (only specify for slow services) + +**IMPORTANT:** Field names in the request dict should match the field names shown by `get_service_details()`, which are already formatted for rosbridge (without leading underscores). + +**Examples:** + +1. **Call a service with no parameters:** + ``` + call_service('/rosapi/topics', 'rosapi/Topics', {}) + ``` + +2. **Call a service with parameters:** + ``` + call_service('/rosapi/topic_type', 'rosapi/TopicType', {'topic': '/cmd_vel'}) + ``` + +3. **Call a service with timeout (for slow services):** + ``` + call_service('/slow_service', 'my_package/SlowService', {}, timeout=10.0) + ``` + +4. **Call turtlesim clear service:** + ``` + call_service('/clear', 'std_srvs/Empty', {}) + ``` + +**Response includes:** +- `service`: The service name +- `service_type`: The service type +- `success`: Whether the call was successful +- `result`: Service response data (if successful) +- `error`: Error message (if failed) + +**Example Response:** +```json +{ + "service": "/rosapi/topics", + "service_type": "rosapi/Topics", + "success": true, + "result": { + "topics": ["/cmd_vel", "/rosout"], + "types": ["geometry_msgs/Twist", "rosgraph_msgs/Log"] + } +} +``` + +## Service Naming Convention + +ROS services use the format: `/service_name` + +- Service names always start with `/` +- Service names are case-sensitive +- Common service patterns: + - `/rosapi/*` - ROS API services + - `/node_name/service_name` - Node-specific services + - `/turtle1/*` - Turtlesim services (if turtlesim is running) + + +## Troubleshooting + +### "No services found" or Empty Service List + +**Problem:** `get_services()` returns no services or a warning + +**Solutions:** +- Verify ROS connection: `connect_to_robot()` +- Check if ROS system is running: `detect_ros_version()` +- Ensure services are actually available in your ROS system +- Some services may be node-specific and only available when nodes are running + +### "Service not found" Error + +**Problem:** `get_service_type()` or `get_service_details()` returns "Service not found" + +**Solutions:** +- Verify the service name is correct (case-sensitive) +- Check if the service is actually available: `get_services()` +- Ensure service name starts with `/` +- Service might have stopped - check again with `get_services()` + +### "Service call failed" Error + +**Problem:** `call_service()` returns an error + +**Solutions:** +- Verify the service name and type are correct +- Check the request structure matches what `get_service_details()` shows +- Ensure field names don't have leading underscores (rosbridge format) +- Verify the service is actually running and available +- Check if the service requires specific parameters +- Try getting service details first to understand the expected request format + +### "Service type not found" Error + +**Problem:** `get_service_details()` returns "Service type not found" + +**Solutions:** +- Verify the service type is correct +- Get the service type first: `get_service_type('/service_name')` +- Ensure the service type format is correct (e.g., 'rosapi/Topics' not '/rosapi/Topics') + +### Wrong Request Format + +**Problem:** Service call fails due to incorrect request format + +**Solutions:** +- Always use `get_service_details()` first to see the expected structure +- Field names should NOT have leading underscores (rosbridge format) +- Use the exact field names shown in `get_service_details()` +- Check data types match what the service expects +- Example: Use `{'topic': '/image'}` not `{'_topic': '/image'}` + +### Timeout Errors + +**Problem:** Service call times out + +**Solutions:** +- Some services are slow - specify a timeout: `call_service(..., timeout=10.0)` +- Check if the service is actually running and responsive +- Verify the service isn't blocked or waiting for something +- Increase the timeout value if the service is known to be slow + +## Tips + +- **Start with `get_services()`** - Always start by discovering what services are available +- **Use `get_service_details()` before calling** - Understand the request/response structure and which node provides the service +- **Field names matter** - Use the exact field names from `get_service_details()` (no leading underscores) +- **Service names are case-sensitive** - `/Service` is different from `/service` +- **Use the resource `ros-mcp://ros-metadata/services/all` for complete overview** - Provides comprehensive information about all services +- **`get_service_details()` includes providers** - No need for a separate call to find which node provides a service +- **Test with simple services first** - Start with services that have no parameters (like `/clear`) +- **Handle timeouts** - Specify timeout for services that might be slow + +## Related Guides + +- **`test-server-tools`** - High-level overview of all ROS MCP Server tools +""" diff --git a/ros_mcp/prompts/test_topics_tools.py b/ros_mcp/prompts/test_topics_tools.py new file mode 100644 index 0000000..bf6e135 --- /dev/null +++ b/ros_mcp/prompts/test_topics_tools.py @@ -0,0 +1,441 @@ +"""Test topic tools prompts for ROS MCP Server.""" + + +def register_test_topics_tools_prompts(mcp): + """Register test topic tools prompts with the MCP server.""" + + @mcp.prompt(name="test-topics-tools") + def test_topics_tools() -> str: + """ + Guide users on how to test and explore the ROS topic tools. + + This prompt provides step-by-step instructions for testing topic operations, + including getting topic lists, topic types, topic details, subscribing, and publishing. + + Returns: + str: Comprehensive guide for testing topic tools + """ + return """# Testing ROS Topic Tools - Detailed Guide + +This is a detailed guide for testing topic tools. For a quick overview of all ROS MCP Server tools, see `test-server-tools`. + +## When to Use This Guide + +Use this detailed guide when: +- The main topic tools from `test-server-tools` are not working +- You need step-by-step instructions for each topic tool +- You need troubleshooting help for specific topic tool issues +- You want to understand topic tool details and advanced usage +- You need to test topic resources or advanced features + +For a quick high-level overview, see `test-server-tools`. + +## Prerequisites + +Before testing topic tools, ensure you have: + +1. **Active ROS connection** - Connect to a ROS system first: + ``` + connect_to_robot(ip='127.0.0.1', port=9090) + ``` + +2. **Running ROS topics** - Make sure you have some topics available in your ROS system. + Common topics include: + - `/cmd_vel` - Velocity commands (geometry_msgs/Twist) + - `/turtle1/pose` - Turtle position (turtlesim/Pose) + - `/turtle1/cmd_vel` - Turtle velocity commands (geometry_msgs/Twist) + - `/rosout` - ROS logging messages + - `/image` - Camera images (sensor_msgs/Image) + +## Topic Tools Overview + +The ROS MCP Server provides the following topic tools: + +1. **get_topics()** - Get list of all available ROS topics +2. **get_topic_type(topic)** - Get the message type for a specific topic +3. **get_topic_details(topic)** - Get detailed information about a topic including type, publishers, and subscribers +4. **get_message_details(message_type)** - Get the complete structure/definition of a message type +5. **subscribe_once(topic, msg_type, ...)** - Subscribe to a topic and return the first message received +6. **subscribe_for_duration(topic, msg_type, duration, ...)** - Subscribe to a topic for a duration and collect messages +7. **publish_once(topic, msg_type, msg)** - Publish a single message to a ROS topic +8. **publish_for_durations(topic, msg_type, messages, durations)** - Publish a sequence of messages with delays + +Additionally, comprehensive information about all topics is available as a resource: +- **ros-mcp://ros-metadata/topics/all** - Get detailed information about all topics (types, publishers, subscribers) + +## Step 1: Get List of All Topics + +Start by discovering what topics are available in your ROS system: + +``` +get_topics() +``` + +This will return: +- `topics`: List of all available topic names +- `types`: List of message types for each topic +- `topic_count`: Total number of topics + +**Example:** +``` +get_topics() +``` + +**Expected Response:** +```json +{ + "topics": ["/cmd_vel", "/rosout", "/turtle1/pose"], + "types": ["geometry_msgs/msg/Twist", "rcl_interfaces/msg/Log", "turtlesim/msg/Pose"], + "topic_count": 3 +} +``` + +## Step 2: Get Topic Type + +Get the message type for a specific topic: + +``` +get_topic_type('/topic_name') +``` + +**Examples:** +``` +get_topic_type('/cmd_vel') +get_topic_type('/turtle1/pose') +get_topic_type('/rosout') +``` + +**Response includes:** +- `topic`: The topic name +- `type`: The message type (e.g., 'geometry_msgs/msg/Twist') + +**Example Response:** +```json +{ + "topic": "/cmd_vel", + "type": "geometry_msgs/msg/Twist" +} +``` + +## Step 3: Get Topic Details + +Get detailed information about a topic, including its type, publishers, and subscribers: + +``` +get_topic_details('/topic_name') +``` + +**Examples:** +``` +get_topic_details('/cmd_vel') +get_topic_details('/turtle1/pose') +get_topic_details('/rosout') +``` + +**Response includes:** +- `topic`: The topic name +- `type`: The message type +- `publishers`: List of nodes that publish to this topic +- `subscribers`: List of nodes that subscribe to this topic +- `publisher_count`: Number of publishers +- `subscriber_count`: Number of subscribers + +**Example Response:** +```json +{ + "topic": "/cmd_vel", + "type": "geometry_msgs/msg/Twist", + "publishers": ["/teleop_turtle"], + "subscribers": ["/turtlesim"], + "publisher_count": 1, + "subscriber_count": 1 +} +``` + +## Step 4: Get Message Details + +Get the complete structure/definition of a message type: + +``` +get_message_details('message_type') +``` + +**Examples:** +``` +get_message_details('geometry_msgs/msg/Twist') +get_message_details('turtlesim/msg/Pose') +get_message_details('std_msgs/msg/String') +``` + +**Response includes:** +- `message_type`: The message type +- `structure`: Dictionary with field names and types + +**Example Response:** +```json +{ + "message_type": "geometry_msgs/msg/Twist", + "structure": { + "geometry_msgs/msg/Twist": { + "fields": { + "linear": "geometry_msgs/msg/Vector3", + "angular": "geometry_msgs/msg/Vector3" + }, + "field_count": 2 + } + } +} +``` + +## Step 5: Get All Topics Details (Resource) + +Get comprehensive information about all topics at once using the resource: + +**Resource URI:** `ros-mcp://ros-metadata/topics/all` + +**Note:** This may take time to execute when there are a large number of topics since it queries each one by one. + +**How to access:** +The resource can be accessed through the MCP resource interface. It returns a JSON string with comprehensive topic information. + +**Response includes:** +- `total_topics`: Total number of topics +- `topics`: Dictionary with details for each topic +- `topic_errors`: List of any errors encountered (if any) + +## Step 6: Subscribe to a Topic (Once) + +Subscribe to a topic and return the first message received: + +``` +subscribe_once(topic='/topic_name', msg_type='message_type', ...) +``` + +**Parameters:** +- `topic` (str): The topic name (e.g., '/cmd_vel') +- `msg_type` (str): The message type (e.g., 'geometry_msgs/msg/Twist') +- `expects_image` (str, optional): 'auto', 'true', or 'false' - hint for image processing +- `timeout` (float, optional): Timeout in seconds (default: 5.0) +- `queue_length` (int, optional): Message queue length +- `throttle_rate_ms` (int, optional): Throttle rate in milliseconds + +**Examples:** + +1. **Basic subscription:** + ``` + subscribe_once(topic='/turtle1/pose', msg_type='turtlesim/msg/Pose') + ``` + +2. **With timeout:** + ``` + subscribe_once(topic='/slow_topic', msg_type='my_package/SlowMsg', timeout=10.0) + ``` + +3. **For image topics:** + ``` + subscribe_once(topic='/camera/image_raw', msg_type='sensor_msgs/msg/Image', expects_image='true') + ``` + +4. **With rate control:** + ``` + subscribe_once(topic='/high_rate_topic', msg_type='sensor_msgs/msg/Image', queue_length=5, throttle_rate_ms=100) + ``` + +**Response:** +- For regular messages: Returns the message data +- For image messages: Returns the image directly as ImageContent. The image is also saved to disk and can be re-viewed with `view_saved_image()` + +## Step 7: Subscribe to a Topic (For Duration) + +Subscribe to a topic for a fixed duration and collect messages: + +``` +subscribe_for_duration(topic='/topic_name', msg_type='message_type', duration=5.0, ...) +``` + +**Parameters:** +- `topic` (str): The topic name +- `msg_type` (str): The message type +- `duration` (float): Duration in seconds (default: 5.0) +- `max_messages` (int): Maximum number of messages to collect (default: 100) +- `expects_image` (str, optional): 'auto', 'true', or 'false' +- `queue_length` (int, optional): Message queue length +- `throttle_rate_ms` (int, optional): Throttle rate in milliseconds + +**Examples:** + +1. **Basic subscription for 5 seconds:** + ``` + subscribe_for_duration(topic='/cmd_vel', msg_type='geometry_msgs/msg/Twist', duration=5.0) + ``` + +2. **Collect up to 10 messages:** + ``` + subscribe_for_duration(topic='/turtle1/pose', msg_type='turtlesim/msg/Pose', duration=10.0, max_messages=10) + ``` + +3. **For image topics:** + ``` + subscribe_for_duration(topic='/camera/image_raw', msg_type='sensor_msgs/msg/Image', duration=5.0, expects_image='true') + ``` + +**Response includes:** +- `topic`: The topic name +- `collected_count`: Number of messages collected +- `messages`: List of collected messages +- `status_errors`: List of any errors encountered + +## Step 8: Publish a Single Message + +Publish a single message to a ROS topic: + +``` +publish_once(topic='/topic_name', msg_type='message_type', msg={...}) +``` + +**Parameters:** +- `topic` (str): ROS topic name (e.g., '/cmd_vel') +- `msg_type` (str): ROS message type (e.g., 'geometry_msgs/msg/Twist') +- `msg` (dict): Message payload as a dictionary + +**Examples:** + +1. **Publish velocity command:** + ``` + publish_once(topic='/cmd_vel', msg_type='geometry_msgs/msg/Twist', msg={'linear': {'x': 1.0}, 'angular': {'z': 0.5}}) + ``` + +2. **Publish string message:** + ``` + publish_once(topic='/chatter', msg_type='std_msgs/msg/String', msg={'data': 'Hello, ROS!'}) + ``` + +**Response:** +- `success`: True if published successfully +- `error`: Error message if failed + +## Step 9: Publish Multiple Messages with Delays + +Publish a sequence of messages with delays between them: + +``` +publish_for_durations(topic='/topic_name', msg_type='message_type', messages=[...], durations=[...]) +``` + +**Parameters:** +- `topic` (str): ROS topic name +- `msg_type` (str): ROS message type +- `messages` (list[dict]): List of message dictionaries +- `durations` (list[float]): List of durations (seconds) to wait between messages + +**Example:** +``` +publish_for_durations( + topic='/cmd_vel', + msg_type='geometry_msgs/msg/Twist', + messages=[ + {'linear': {'x': 1.0}, 'angular': {'z': 0.0}}, + {'linear': {'x': 0.0}, 'angular': {'z': 0.0}} + ], + durations=[2.0, 1.0] +) +``` + +This publishes the first message, waits 2 seconds, publishes the second message, then waits 1 second. + +**Response includes:** +- `success`: True if published successfully +- `published_count`: Number of messages successfully published +- `total_messages`: Total number of messages attempted +- `errors`: List of any errors encountered + +## Topic Naming Convention + +ROS topics use the format: `/topic_name` + +- Topic names always start with `/` +- Topic names are case-sensitive +- Common topic patterns: + - `/cmd_vel` - Velocity commands + - `/node_name/topic_name` - Node-specific topics + - `/turtle1/*` - Turtlesim topics (if turtlesim is running) + + +## Troubleshooting + +### "No topics found" or Empty Topic List + +**Problem:** `get_topics()` returns no topics or a warning + +**Solutions:** +- Verify ROS connection: `connect_to_robot()` +- Check if ROS system is running: `detect_ros_version()` +- Ensure topics are actually being published in your ROS system +- Try launching some nodes that publish topics + +### "Topic not found" Error + +**Problem:** `get_topic_type()` or `get_topic_details()` returns "Topic not found" + +**Solutions:** +- Verify the topic name is correct (case-sensitive) +- Check if the topic is actually available: `get_topics()` +- Ensure topic name starts with `/` +- Topic might have stopped publishing - check again with `get_topics()` + +### "Service call failed" Error + +**Problem:** Service call to get topic information fails + +**Solutions:** +- Verify rosbridge connection is active +- Check if `/rosapi/topics` or `/rosapi/topic_type` services are available +- Try reconnecting: `connect_to_robot()` +- Check ROS system is responsive + +### Subscription Timeout + +**Problem:** `subscribe_once()` or `subscribe_for_duration()` times out + +**Solutions:** +- Check if the topic is actually publishing: `get_topic_details('/topic_name')` (check publishers list) +- Increase the timeout value +- Verify the message type is correct +- Check if the topic is publishing at a very low rate + +### No Messages Received + +**Problem:** Subscription returns no messages + +**Solutions:** +- Verify the topic is publishing: `get_topic_details('/topic_name')` (check publishers list) +- Check the message type matches: `get_topic_type('/topic_name')` +- Ensure the topic is active and publishing messages +- Try increasing duration or timeout + +### Publish Errors + +**Problem:** `publish_once()` or `publish_for_durations()` fails + +**Solutions:** +- Verify the topic name is correct +- Check the message type matches what subscribers expect +- Ensure the message structure matches the message type definition +- Use `get_message_details()` to verify the correct structure +- Check if there are any subscribers (publishing to a topic with no subscribers is usually fine, but verify) + +## Tips + +- **Start with `get_topics()`** - Always start by discovering what topics are available +- **Use `get_topic_details()` for complete info** - Includes type, publishers, and subscribers in one call +- **Check message structure before publishing** - Use `get_message_details()` to understand the expected format +- **Topic names are case-sensitive** - `/Topic` is different from `/topic` +- **Use the resource `ros-mcp://ros-metadata/topics/all` for complete overview** - Provides comprehensive information about all topics +- **For image topics, use `expects_image='true'`** - Improves processing speed +- **Test subscriptions with short durations first** - Start with 1-2 seconds to verify it works +- **Verify publishers/subscribers** - Use `get_topic_details()` to see both publishers and subscribers in one call + +## Related Guides + +- **`test-server-tools`** - High-level overview of all ROS MCP Server tools +""" diff --git a/ros_mcp/resources/__init__.py b/ros_mcp/resources/__init__.py new file mode 100644 index 0000000..f933ca6 --- /dev/null +++ b/ros_mcp/resources/__init__.py @@ -0,0 +1,13 @@ +"""Resources package for ROS MCP Server. + +Functions to register resources with the MCP server instance. +""" + +from ros_mcp.resources.robot_specs import register_robot_spec_resources +from ros_mcp.resources.ros_metadata import register_ros_metadata_resources + + +def register_all_resources(mcp, ws_manager): + """Register all resources with the MCP server instance.""" + register_robot_spec_resources(mcp) + register_ros_metadata_resources(mcp, ws_manager) diff --git a/ros_mcp/resources/robot_specs.py b/ros_mcp/resources/robot_specs.py new file mode 100644 index 0000000..6c0ffcf --- /dev/null +++ b/ros_mcp/resources/robot_specs.py @@ -0,0 +1,46 @@ +"""Resources for robot specifications.""" + +import json +from pathlib import Path + + +def register_robot_spec_resources(mcp): + """Register robot specification resources with the MCP server.""" + + # Get the robot_specifications directory path + # From ros_mcp/resources/robot_specs.py, go up to project root + specs_dir = Path(__file__).parent.parent.parent / "robot_specifications" + + @mcp.resource("ros-mcp://robot-specs/get_verified_robots_list") + def get_verified_robots_list() -> str: + """ + Get all available robot specifications. + + Returns: + str: JSON string with list of available robot names + """ + try: + if not specs_dir.exists(): + return json.dumps( + { + "error": f"Robot specifications directory not found: {specs_dir}", + "robot_specifications": [], + } + ) + + # Find all YAML files + yaml_files = list(specs_dir.glob("*.yaml")) + + # Filter out template files only + robot_names = [file.stem for file in yaml_files if not file.stem.startswith("YOUR_")] + robot_names.sort() + + return json.dumps({"robot_specifications": robot_names, "count": len(robot_names)}) + + except Exception as e: + return json.dumps( + { + "error": f"Failed to list robot specifications: {str(e)}", + "robot_specifications": [], + } + ) diff --git a/ros_mcp/resources/ros_metadata.py b/ros_mcp/resources/ros_metadata.py new file mode 100644 index 0000000..f661838 --- /dev/null +++ b/ros_mcp/resources/ros_metadata.py @@ -0,0 +1,643 @@ +"""Resources for ROS metadata and discovery information.""" + +import json + +from ros_mcp.utils.response import _extract_error, _safe_get_values +from ros_mcp.utils.rosapi_types import ( + DetectionError, + RosVersion, + get_distro, + get_ros_version, + rosapi_service, + rosapi_type, +) +from ros_mcp.utils.websocket import WebSocketManager + + +def register_ros_metadata_resources(mcp, ws_manager: WebSocketManager): + """Register ROS metadata resources with the MCP server.""" + + @mcp.resource("ros-mcp://ros-metadata/all") + def get_all_ros_metadata() -> str: + """ + Get all ROS metadata including topics, services, nodes, and parameters. + + Returns: + str: JSON string with comprehensive ROS system information + """ + try: + metadata = { + "topics": [], + "services": [], + "nodes": [], + "parameters": [], + "ros_version": None, + "errors": [], + } + + # Get ROS version from the resolver detected at connect time + try: + metadata["ros_version"] = { + "version": "2" if get_ros_version() == RosVersion.ROS2 else "1", + "distro": get_distro(), + } + except DetectionError as e: + metadata["errors"].append(f"Failed to get ROS version: {str(e)}") + + # Get topics + try: + topics_message = { + "op": "call_service", + "service": rosapi_service("topics"), + "type": rosapi_type("Topics"), + "args": {}, + "id": "get_topics_request", + } + with ws_manager: + response = ws_manager.request(topics_message) + if response and "values" in response: + topics = response["values"].get("topics", []) + types = response["values"].get("types", []) + # Handle case where types might be empty or missing + if types and len(types) == len(topics): + metadata["topics"] = [ + {"name": topic, "type": topic_type} + for topic, topic_type in zip(topics, types) + ] + else: + metadata["topics"] = [ + {"name": topic, "type": "unknown"} for topic in topics + ] + except Exception as e: + metadata["errors"].append(f"Failed to get topics: {str(e)}") + + # Get services + try: + services_message = { + "op": "call_service", + "service": rosapi_service("services"), + "type": rosapi_type("Services"), + "args": {}, + "id": "get_services_request", + } + with ws_manager: + response = ws_manager.request(services_message) + values = _safe_get_values(response) + if values is not None: + services = values.get("services", []) + types = values.get("types", []) + # Handle case where types might be empty or missing + if types and len(types) == len(services): + metadata["services"] = [ + {"name": service, "type": service_type} + for service, service_type in zip(services, types) + ] + else: + # If types aren't available, just return service names + # Types can be fetched separately if needed + metadata["services"] = [ + {"name": service, "type": "unknown"} for service in services + ] + except Exception as e: + metadata["errors"].append(f"Failed to get services: {str(e)}") + + # Get nodes + try: + nodes_message = { + "op": "call_service", + "service": rosapi_service("nodes"), + "type": rosapi_type("Nodes"), + "args": {}, + "id": "get_nodes_request", + } + with ws_manager: + response = ws_manager.request(nodes_message) + values = _safe_get_values(response) + if values is not None: + metadata["nodes"] = values.get("nodes", []) + except Exception as e: + metadata["errors"].append(f"Failed to get nodes: {str(e)}") + + # Get parameters (ROS 2 only) + try: + params_message = { + "op": "call_service", + "service": rosapi_service("get_param_names"), + "type": rosapi_type("GetParamNames"), + "args": {}, + "id": "get_parameters_request", + } + with ws_manager: + response = ws_manager.request(params_message) + values = _safe_get_values(response) + if values is not None: + metadata["parameters"] = values.get("names", []) + except Exception: + # Parameters might not be available in ROS1 or if service doesn't exist + pass + + # Add summary counts + metadata["summary"] = { + "total_topics": len(metadata["topics"]), + "total_services": len(metadata["services"]), + "total_nodes": len(metadata["nodes"]), + "total_parameters": len(metadata["parameters"]), + "has_errors": len(metadata["errors"]) > 0, + } + + return json.dumps(metadata, indent=2) + + except Exception as e: + return json.dumps( + { + "error": f"Failed to get ROS metadata: {str(e)}", + "topics": [], + "services": [], + "nodes": [], + "parameters": [], + } + ) + + @mcp.resource("ros-mcp://ros-metadata/nodes/all") + def get_nodes_details() -> str: + """ + Get comprehensive information about all ROS nodes including their publishers, subscribers, and services. + + Returns: + str: JSON string with detailed information about all nodes including: + - Node names and details + - Publishers for each node + - Subscribers for each node + - Services provided by each node + - Connection counts and statistics + """ + try: + # First get all nodes + nodes_message = { + "op": "call_service", + "service": rosapi_service("nodes"), + "type": rosapi_type("Nodes"), + "args": {}, + "id": "get_nodes_request", + } + + with ws_manager: + nodes_response = ws_manager.request(nodes_message) + + nodes_values = _safe_get_values(nodes_response) + if nodes_values is None: + return json.dumps( + { + "error": "Failed to get nodes list", + "total_nodes": 0, + "nodes": {}, + "node_errors": [], + } + ) + + nodes = nodes_values.get("nodes", []) + node_details = {} + + # Get details for each node + node_errors = [] + for node in nodes: + # Get node details (publishers, subscribers, services) + node_details_message = { + "op": "call_service", + "service": rosapi_service("node_details"), + "type": rosapi_type("NodeDetails"), + "args": {"node": node}, + "id": f"get_node_details_{node.replace('/', '_')}", + } + + node_details_response = ws_manager.request(node_details_message) + + nd_values = _safe_get_values(node_details_response) + if nd_values is not None: + # Extract publishers, subscribers, and services from the response + # Note: rosapi uses "publishing" and "subscribing" field names + publishers = nd_values.get("publishing", []) + subscribers = nd_values.get("subscribing", []) + services = nd_values.get("services", []) + + node_details[node] = { + "publishers": publishers, + "subscribers": subscribers, + "services": services, + "publisher_count": len(publishers), + "subscriber_count": len(subscribers), + "service_count": len(services), + } + elif ( + node_details_response + and "result" in node_details_response + and not node_details_response["result"] + ): + error_msg = _extract_error(node_details_response) + node_errors.append(f"Node {node}: {error_msg}") + else: + node_errors.append(f"Node {node}: Failed to get node details") + + result = { + "total_nodes": len(nodes), + "nodes": node_details, + "node_errors": node_errors, # Include any errors encountered during inspection + } + + return json.dumps(result, indent=2) + except Exception as e: + return json.dumps( + { + "error": f"Failed to inspect all nodes: {str(e)}", + "total_nodes": 0, + "nodes": {}, + "node_errors": [], + } + ) + + @mcp.resource("ros-mcp://ros-metadata/services/all") + def get_services_details() -> str: + """ + Get comprehensive information about all ROS services including types and providers. + + Returns: + str: JSON string with detailed information about all services including: + - Service names and types + - Provider nodes for each service + - Connection counts and statistics + """ + try: + # First get all services + services_message = { + "op": "call_service", + "service": rosapi_service("services"), + "type": rosapi_type("Services"), + "args": {}, + "id": "inspect_all_services_request_1", + } + + with ws_manager: + services_response = ws_manager.request(services_message) + + svc_values = _safe_get_values(services_response) + if svc_values is None: + return json.dumps( + { + "error": "Failed to get services list", + "total_services": 0, + "services": {}, + "service_errors": [], + } + ) + + services = svc_values.get("services", []) + service_details = {} + + # Get details for each service + service_errors = [] + for service in services: + # Get service type + type_message = { + "op": "call_service", + "service": rosapi_service("service_type"), + "type": rosapi_type("ServiceType"), + "args": {"service": service}, + "id": f"get_type_{service.replace('/', '_')}", + } + + type_response = ws_manager.request(type_message) + service_type = "" + type_values = _safe_get_values(type_response) + if type_values is not None: + service_type = type_values.get("type", "unknown") + elif type_response and "error" in type_response: + service_errors.append(f"Service {service}: {type_response['error']}") + + # Get service provider (using service_node instead of service_providers) + provider_message = { + "op": "call_service", + "service": rosapi_service("service_node"), + "type": rosapi_type("ServiceNode"), + "args": {"service": service}, + "id": f"get_provider_{service.replace('/', '_')}", + } + + provider_response = ws_manager.request(provider_message) + providers = [] + + # Handle different response formats safely + prov_values = _safe_get_values(provider_response) + if prov_values is not None: + node = prov_values.get("node", "") + if node: + providers = [node] + elif provider_response and isinstance(provider_response, dict): + if isinstance(provider_response.get("result"), dict): + node = provider_response["result"].get("node", "") + if node: + providers = [node] + elif "error" in provider_response: + service_errors.append( + f"Service {service} provider: {provider_response['error']}" + ) + elif provider_response is False: + service_errors.append(f"Service {service} provider: No response received") + elif provider_response is True: + service_errors.append( + f"Service {service} provider: Unexpected boolean response" + ) + + service_details[service] = { + "type": service_type, + "providers": providers, + "provider_count": len(providers), + } + + result = { + "total_services": len(services), + "services": service_details, + "service_errors": service_errors, # Include any errors encountered during inspection + } + + return json.dumps(result, indent=2) + except Exception as e: + return json.dumps( + { + "error": f"Failed to inspect all services: {str(e)}", + "total_services": 0, + "services": {}, + "service_errors": [], + } + ) + + @mcp.resource("ros-mcp://ros-metadata/topics/all") + def get_topics_details() -> str: + """ + Get comprehensive information about all ROS topics including publishers, subscribers, and message types. + + Returns: + str: JSON string with detailed information about all topics including: + - Topic names and types + - Publishers for each topic + - Subscribers for each topic + - Connection counts and statistics + """ + try: + # First get all topics + topics_message = { + "op": "call_service", + "service": rosapi_service("topics"), + "type": rosapi_type("Topics"), + "args": {}, + "id": "inspect_all_topics_request_1", + } + + with ws_manager: + topics_response = ws_manager.request(topics_message) + + topic_vals = _safe_get_values(topics_response) + if topic_vals is None: + return json.dumps( + { + "error": "Failed to get topics list", + "total_topics": 0, + "topics": {}, + "topic_errors": [], + } + ) + + topics = topic_vals.get("topics", []) + types = topic_vals.get("types", []) + topic_details = {} + + # Get details for each topic + topic_errors = [] + for i, topic in enumerate(topics): + # Get topic type + topic_type = types[i] if i < len(types) else "unknown" + + # Get publishers for this topic + publishers_message = { + "op": "call_service", + "service": rosapi_service("publishers"), + "type": rosapi_type("Publishers"), + "args": {"topic": topic}, + "id": f"get_publishers_{topic.replace('/', '_')}", + } + + publishers_response = ws_manager.request(publishers_message) + publishers = [] + pub_values = _safe_get_values(publishers_response) + if pub_values is not None: + publishers = pub_values.get("publishers", []) + elif ( + publishers_response + and "result" in publishers_response + and not publishers_response["result"] + ): + error_msg = _extract_error(publishers_response) + topic_errors.append(f"Topic {topic} publishers: {error_msg}") + + # Get subscribers for this topic + subscribers_message = { + "op": "call_service", + "service": rosapi_service("subscribers"), + "type": rosapi_type("Subscribers"), + "args": {"topic": topic}, + "id": f"get_subscribers_{topic.replace('/', '_')}", + } + + subscribers_response = ws_manager.request(subscribers_message) + subscribers = [] + sub_values = _safe_get_values(subscribers_response) + if sub_values is not None: + subscribers = sub_values.get("subscribers", []) + elif ( + subscribers_response + and "result" in subscribers_response + and not subscribers_response["result"] + ): + error_msg = _extract_error(subscribers_response) + topic_errors.append(f"Topic {topic} subscribers: {error_msg}") + + topic_details[topic] = { + "type": topic_type, + "publishers": publishers, + "subscribers": subscribers, + "publisher_count": len(publishers), + "subscriber_count": len(subscribers), + } + + result = { + "total_topics": len(topics), + "topics": topic_details, + "topic_errors": topic_errors, # Include any errors encountered during inspection + } + + return json.dumps(result, indent=2) + except Exception as e: + return json.dumps( + { + "error": f"Failed to inspect all topics: {str(e)}", + "total_topics": 0, + "topics": {}, + "topic_errors": [], + } + ) + + @mcp.resource("ros-mcp://ros-metadata/actions/all") + def get_actions_details() -> str: + """ + Get comprehensive information about all ROS actions including types and available actions. + + Returns: + str: JSON string with detailed information about all actions including: + - Action names and types + - Action status and availability + - Connection counts and statistics + """ + try: + # Check if required action services are available + required_services = [rosapi_service("action_servers")] + + with ws_manager: + # Get available services to check compatibility + services_message = { + "op": "call_service", + "service": rosapi_service("services"), + "type": rosapi_type("Services"), + "args": {}, + "id": "check_services_for_inspect_actions", + } + + services_response = ws_manager.request(services_message) + if not services_response or not isinstance(services_response, dict): + return json.dumps( + { + "error": "Failed to check service availability", + "total_actions": 0, + "actions": {}, + "action_errors": [], + "compatibility": { + "issue": "Cannot determine available services", + "required_services": required_services, + "suggestion": "Ensure rosbridge is running and rosapi is available", + }, + } + ) + + svc_vals = _safe_get_values(services_response) + available_services = svc_vals.get("services", []) if svc_vals else [] + missing_services = [ + svc for svc in required_services if svc not in available_services + ] + + if missing_services: + return json.dumps( + { + "error": "Action inspection not supported by this rosbridge/rosapi version", + "total_actions": 0, + "actions": {}, + "action_errors": [], + "compatibility": { + "issue": "Required action services are not available", + "missing_services": missing_services, + "required_services": required_services, + "available_services": [ + s for s in available_services if "action" in s + ], + "suggestions": [ + "This rosbridge version doesn't support action inspection services", + "Use get_actions() to list available actions", + "Consider upgrading rosbridge or using a different implementation", + ], + "note": f"Action inspection requires {rosapi_service('action_servers')} service", + }, + } + ) + + # First get all actions + actions_message = { + "op": "call_service", + "service": rosapi_service("action_servers"), + "type": rosapi_type("ActionServers"), + "args": {}, + "id": "inspect_all_actions_request_1", + } + + actions_response = ws_manager.request(actions_message) + + action_vals = _safe_get_values(actions_response) + if action_vals is None: + return json.dumps( + { + "error": "Failed to get actions list", + "total_actions": 0, + "actions": {}, + "action_errors": [], + } + ) + + actions = action_vals.get("action_servers", []) + action_details = {} + + # Get details for each action + action_errors = [] + for action in actions: + # Try to get action type (this may not always work due to rosapi limitations) + action_type = "unknown" + + # Known action type mappings for common actions + action_type_map = { + "/turtle1/rotate_absolute": "turtlesim/action/RotateAbsolute", + # Add more mappings as needed based on common ROS actions + } + + if action in action_type_map: + action_type = action_type_map[action] + else: + # Try to derive from interfaces + interfaces_message = { + "op": "call_service", + "service": rosapi_service("interfaces"), + "type": rosapi_type("Interfaces"), + "args": {}, + "id": f"get_interfaces_{action.replace('/', '_')}", + } + + interfaces_response = ws_manager.request(interfaces_message) + iface_vals = _safe_get_values(interfaces_response) + if iface_vals is not None: + interfaces = iface_vals.get("interfaces", []) + action_interfaces = [ + iface for iface in interfaces if "/action/" in iface + ] + + # Try to match based on action name patterns + action_name_part = action.split("/")[-1] + for iface in action_interfaces: + if action_name_part.lower() in iface.lower(): + action_type = iface + break + + action_details[action] = { + "type": action_type, + "status": "available" if action_type != "unknown" else "type_unknown", + } + + result = { + "total_actions": len(actions), + "actions": action_details, + "action_errors": action_errors, + } + + return json.dumps(result, indent=2) + except Exception as e: + return json.dumps( + { + "error": f"Failed to inspect all actions: {str(e)}", + "total_actions": 0, + "actions": {}, + "action_errors": [], + } + ) diff --git a/ros_mcp/tools/__init__.py b/ros_mcp/tools/__init__.py new file mode 100644 index 0000000..cfdcadc --- /dev/null +++ b/ros_mcp/tools/__init__.py @@ -0,0 +1,45 @@ +"""ROS MCP Tools - Tool implementations organized by category. + +This module provides the main registration function to register all ROS MCP tools +with a FastMCP instance. +""" + +from fastmcp import FastMCP + +from ros_mcp.tools.actions import register_action_tools +from ros_mcp.tools.connection import register_connection_tools +from ros_mcp.tools.images import register_image_tools +from ros_mcp.tools.nodes import register_node_tools +from ros_mcp.tools.parameters import register_parameter_tools +from ros_mcp.tools.robot_config import register_robot_config_tools +from ros_mcp.tools.services import register_service_tools +from ros_mcp.tools.topics import register_topic_tools +from ros_mcp.utils.websocket import WebSocketManager + + +def register_all_tools( + mcp: FastMCP, + ws_manager: WebSocketManager, + rosbridge_ip: str = "127.0.0.1", + rosbridge_port: int = 9090, +) -> None: + """Register all ROS MCP tools with the provided FastMCP instance. + + This function registers all available tools with the provided WebSocketManager. + + Args: + mcp: FastMCP instance to register tools with + ws_manager: WebSocketManager instance to use for ROS connections + rosbridge_ip: IP address of the rosbridge server (default: "127.0.0.1") + rosbridge_port: Port of the rosbridge server (default: 9090) + """ + + # Register all tool categories + register_action_tools(mcp, ws_manager) + register_connection_tools(mcp, ws_manager, rosbridge_ip, rosbridge_port) + register_robot_config_tools(mcp, ws_manager) + register_image_tools(mcp) + register_node_tools(mcp, ws_manager) + register_parameter_tools(mcp, ws_manager) + register_service_tools(mcp, ws_manager) + register_topic_tools(mcp, ws_manager) diff --git a/ros_mcp/tools/actions.py b/ros_mcp/tools/actions.py new file mode 100644 index 0000000..10e71bf --- /dev/null +++ b/ros_mcp/tools/actions.py @@ -0,0 +1,481 @@ +"""Action tools for ROS MCP.""" + +import json +import time +import uuid + +from fastmcp import Context, FastMCP +from mcp.types import ToolAnnotations + +from ros_mcp.utils.response import _check_response, _safe_get_values +from ros_mcp.utils.rosapi_types import rosapi_service, rosapi_type +from ros_mcp.utils.websocket import WebSocketManager + +# Action detail parts: result key โ†’ (rosapi service name, rosapi type name) +_ACTION_PARTS = { + "goal": ("action_goal_details", "ActionGoalDetails"), + "result": ("action_result_details", "ActionResultDetails"), + "feedback": ("action_feedback_details", "ActionFeedbackDetails"), +} + + +def _parse_typedef(typedef: dict) -> dict: + """Parse a rosapi typedef into a field-name -> type mapping. + + Mirrors the shape produced by get_message_details (topics) and + get_service_details (services): a ``fields`` dict plus ``field_count``. + """ + field_names = typedef.get("fieldnames", []) + field_types = typedef.get("fieldtypes", []) + + fields = {} + for name, ftype in zip(field_names, field_types): + fields[name] = ftype + + return {"fields": fields, "field_count": len(fields)} + + +def _fetch_action_part(ws_manager: WebSocketManager, action_type: str, part: str) -> dict: + """Fetch one part (goal/result/feedback) of an action definition.""" + service, type_name = _ACTION_PARTS[part] + message = { + "op": "call_service", + "service": rosapi_service(service), + "type": rosapi_type(type_name), + "args": {"type": action_type}, + "id": f"get_action_{part}_{action_type.replace('/', '_')}", + } + response = ws_manager.request(message) + + error = _check_response(response) + if error: + return {} + + values = _safe_get_values(response) + if values is not None: + typedefs = values.get("typedefs", []) + if typedefs: + return _parse_typedef(typedefs[0]) + return {} + + +def register_action_tools( + mcp: FastMCP, + ws_manager: WebSocketManager, +) -> None: + """Register all action-related tools.""" + + @mcp.tool( + description=("Get list of all available ROS actions.\nExample:\nget_actions()"), + annotations=ToolAnnotations( + title="Get Actions", + readOnlyHint=True, + ), + ) + def get_actions() -> dict: + """ + Get list of all available ROS actions. + + Returns: + dict: Contains list of all active actions, + or a message string if no actions are found. + """ + message = { + "op": "call_service", + "service": rosapi_service("action_servers"), + "type": rosapi_type("ActionServers"), + "args": {}, + "id": "get_actions_request_1", + } + + with ws_manager: + response = ws_manager.request(message) + + error = _check_response(response) + if error: + return error + + values = _safe_get_values(response) + if values is not None: + actions = values.get("action_servers", []) + return {"actions": actions, "action_count": len(actions)} + return {"warning": "No actions found"} + + @mcp.tool( + description=( + "Get complete action details including goal, result, and feedback structures. " + "Requires the action type; if you don't know it, call without action_type to get " + "the list of available types back in the error.\n" + "Example:\n" + "get_action_details('/turtle1/rotate_absolute', 'turtlesim/action/RotateAbsolute')" + ), + annotations=ToolAnnotations( + title="Get Action Details", + readOnlyHint=True, + ), + ) + def get_action_details(action: str, action_type: str = "") -> dict: + """ + Get complete action details including goal, result, and feedback structures. + + Args: + action (str): The action name (e.g., '/turtle1/rotate_absolute') + action_type (str): The action type (e.g., 'turtlesim/action/RotateAbsolute'). + Required. Call without action_type to get the available types back + in the error (they are also listed by the interfaces service, not + by get_actions(), which returns action *names*). + + Returns: + dict: Contains complete action definition with goal, result, and feedback structures. + """ + if not action or not action.strip(): + return {"error": "Action name cannot be empty"} + + with ws_manager: + # Fetch the available action interfaces. We need them to suggest a + # type when none was given, and to validate a supplied type before + # querying its details: asking rosapi for the goal/result/feedback + # of a non-existent type crashes the ROS 2 rosapi node, taking down + # all rosapi services. + interfaces_response = ws_manager.request( + { + "op": "call_service", + "service": rosapi_service("interfaces"), + "type": rosapi_type("Interfaces"), + "args": {}, + "id": f"get_interfaces_for_{action.replace('/', '_')}", + } + ) + interfaces_error = _check_response(interfaces_response) + iface_values = _safe_get_values(interfaces_response) + available = ( + [i for i in iface_values.get("interfaces", []) if "/action/" in i] + if iface_values is not None + else [] + ) + + if not action_type or not action_type.strip(): + return { + "error": f"action_type is required for {action}", + "action": action, + "available_action_types": available, + } + + # If the interfaces list is unavailable we cannot validate the type, + # and we must not fall through to the detail services (a bad type + # crashes rosapi). Report that distinctly from a genuine "not found". + if interfaces_error: + return { + "error": ( + f"Cannot validate action_type for {action}: " + "the action interfaces service is unavailable" + ), + "action": action, + "action_type": action_type, + } + + if action_type not in available: + return { + "error": f"Action type '{action_type}' not found", + "action": action, + "action_type": action_type, + "available_action_types": available, + } + + # Fetch goal, result, and feedback structures + parts = { + part: _fetch_action_part(ws_manager, action_type, part) for part in _ACTION_PARTS + } + + if not any(parts.values()): + return { + "error": f"Action type {action_type} found but has no definition", + "action": action, + "action_type": action_type, + } + + return {"action": action, "action_type": action_type, **parts} + + @mcp.tool( + description=( + "Get action status for a specific action name. Works only with ROS 2.\n" + "Example:\nget_action_status('/turtle1/rotate_absolute')" + ), + annotations=ToolAnnotations( + title="Get Action Status", + readOnlyHint=True, + ), + ) + def get_action_status(action_name: str) -> dict: + """ + Get action status for a specific action name. Works only with ROS 2. + + Args: + action_name (str): The action name (e.g., '/turtle1/rotate_absolute') + + Returns: + dict: Contains action status information including active goals and their status. + """ + if not action_name or not action_name.strip(): + return {"error": "Action name cannot be empty"} + + if not action_name.startswith("/"): + action_name = f"/{action_name}" + + status_topic = f"{action_name}/_action/status" + status_msg_type = "action_msgs/msg/GoalStatusArray" + + status_map = { + 0: "STATUS_UNKNOWN", + 1: "STATUS_ACCEPTED", + 2: "STATUS_EXECUTING", + 3: "STATUS_CANCELING", + 4: "STATUS_SUCCEEDED", + 5: "STATUS_CANCELED", + 6: "STATUS_ABORTED", + } + + try: + with ws_manager: + send_error = ws_manager.send( + { + "op": "subscribe", + "topic": status_topic, + "type": status_msg_type, + "id": f"get_action_status_{action_name.replace('/', '_')}", + } + ) + if send_error: + return { + "error": f"Failed to subscribe to status topic: {send_error}", + "action_name": action_name, + } + + response = ws_manager.receive(timeout=3.0) + + # Unsubscribe regardless of result + ws_manager.send({"op": "unsubscribe", "topic": status_topic}) + + if not response: + # An idle action server may not publish status; that is not + # an error, just no goals in flight. + return { + "action_name": action_name, + "active_goals": [], + "goal_count": 0, + "note": "No status published; the action is idle or not running", + } + + response_data = json.loads(response) + + if response_data.get("op") == "status" and response_data.get("level") == "error": + return { + "error": f"Action status error: {response_data.get('msg', 'Unknown error')}", + "action_name": action_name, + } + + if "msg" not in response_data or "status_list" not in response_data.get("msg", {}): + return { + "action_name": action_name, + "active_goals": [], + "goal_count": 0, + } + + active_goals = [] + for item in response_data["msg"]["status_list"]: + goal_info = item.get("goal_info", {}) + status = item.get("status", -1) + stamp = goal_info.get("stamp", {}) + active_goals.append( + { + "goal_id": goal_info.get("goal_id", {}).get("uuid", "unknown"), + "status": status, + "status_text": status_map.get(status, "UNKNOWN"), + "timestamp": f"{stamp.get('sec', 0)}.{stamp.get('nanosec', 0)}", + } + ) + + return { + "action_name": action_name, + "active_goals": active_goals, + "goal_count": len(active_goals), + } + + except json.JSONDecodeError as e: + return {"error": f"Failed to parse status response: {e}"} + except Exception as e: + return {"error": f"Failed to get action status: {e}", "action_name": action_name} + + @mcp.tool( + description=( + "Send a goal to a ROS action server. Works only with ROS 2.\n" + "Example:\nsend_action_goal('/turtle1/rotate_absolute', 'turtlesim/action/RotateAbsolute', {'theta': 1.57})" + ), + annotations=ToolAnnotations( + title="Send Action Goal", + destructiveHint=True, + ), + ) + async def send_action_goal( + action_name: str, + action_type: str, + goal: dict, + timeout: float = None, # type: ignore[assignment] # See issue #140 + ctx: Context = None, # type: ignore[assignment] # See issue #140 + ) -> dict: + """ + Send a goal to a ROS action server. Works only with ROS 2. + + Args: + action_name (str): The name of the action to call (e.g., '/turtle1/rotate_absolute') + action_type (str): The type of the action (e.g., 'turtlesim/action/RotateAbsolute') + goal (dict): The goal message to send + timeout (float): Timeout for action completion in seconds. If None, uses ws_manager.default_timeout. + + Returns: + dict: Contains action response including goal_id, status, and result. + """ + if not action_name or not action_name.strip(): + return {"error": "Action name cannot be empty"} + if not action_type or not action_type.strip(): + return {"error": "Action type cannot be empty"} + if not goal: + return {"error": "Goal cannot be empty"} + + if not action_name.startswith("/"): + action_name = f"/{action_name}" + + if timeout is None: + timeout = ws_manager.default_timeout + + goal_id = f"goal_{int(time.time() * 1000)}_{uuid.uuid4().hex[:8]}" + + message = { + "op": "send_action_goal", + "id": goal_id, + "action": action_name, + "action_type": action_type, + "args": goal, + "feedback": True, + } + + with ws_manager: + send_error = ws_manager.send(message) + if send_error: + return { + "error": f"Failed to send action goal: {send_error}", + "action": action_name, + "success": False, + } + + start_time = time.time() + last_feedback = None + feedback_count = 0 + + while time.time() - start_time < timeout: + remaining = timeout - (time.time() - start_time) + response = ws_manager.receive(remaining) + + if not response: + continue + + try: + msg_data = json.loads(response) + except json.JSONDecodeError: + continue + + if msg_data.get("op") == "action_result": + if ctx: + try: + await ctx.report_progress( + progress=feedback_count, total=None, message="Action completed" + ) + except Exception: + pass + return { + "action": action_name, + "action_type": action_type, + "success": True, + "goal_id": goal_id, + "status": msg_data.get("status", "unknown"), + "result": msg_data.get("values", {}), + } + + if msg_data.get("op") == "action_feedback": + feedback_count += 1 + last_feedback = msg_data + if ctx: + try: + await ctx.report_progress( + progress=feedback_count, + total=None, + message=f"Feedback #{feedback_count}: {str(msg_data.get('values', {}))[:100]}", + ) + except Exception: + pass + + # Timeout + result = { + "action": action_name, + "action_type": action_type, + "success": False, + "goal_id": goal_id, + "error": f"Action timed out after {timeout} seconds", + } + if last_feedback: + result["last_feedback"] = last_feedback.get("values", {}) + result["feedback_count"] = feedback_count + return result + + @mcp.tool( + description=( + "Cancel a specific action goal. Works only with ROS 2.\n" + "Example:\ncancel_action_goal('/turtle1/rotate_absolute', 'goal_1758653551839_21acd486')" + ), + annotations=ToolAnnotations( + title="Cancel Action Goal", + destructiveHint=True, + ), + ) + def cancel_action_goal(action_name: str, goal_id: str) -> dict: + """ + Cancel a specific action goal. Works only with ROS 2. + + Args: + action_name (str): The name of the action (e.g., '/turtle1/rotate_absolute') + goal_id (str): The goal ID to cancel + + Returns: + dict: Contains cancellation status and result. + """ + if not action_name or not action_name.strip(): + return {"error": "Action name cannot be empty"} + if not goal_id or not goal_id.strip(): + return {"error": "Goal ID cannot be empty"} + + if not action_name.startswith("/"): + action_name = f"/{action_name}" + + with ws_manager: + send_error = ws_manager.send( + { + "op": "cancel_action_goal", + "id": goal_id, + "action": action_name, + } + ) + if send_error: + return { + "error": f"Failed to send cancel request: {send_error}", + "action": action_name, + "goal_id": goal_id, + } + + # success here confirms the request was sent, not that the server + # accepted it โ€” the action may still be executing. + return { + "action": action_name, + "goal_id": goal_id, + "success": True, + "note": "Cancel request sent; the action may still be executing", + } diff --git a/ros_mcp/tools/connection.py b/ros_mcp/tools/connection.py new file mode 100644 index 0000000..95bbd29 --- /dev/null +++ b/ros_mcp/tools/connection.py @@ -0,0 +1,209 @@ +"""Connection tools for ROS MCP.""" + +from concurrent.futures import ThreadPoolExecutor, as_completed +from typing import Any, Union + +from fastmcp import FastMCP +from mcp.types import ToolAnnotations + +from ros_mcp.utils.network_utils import ping_ip_and_port +from ros_mcp.utils.rosapi_types import detect_rosapi_types +from ros_mcp.utils.websocket import WebSocketManager + + +def register_connection_tools( + mcp: FastMCP, + ws_manager: WebSocketManager, + default_ip: str, + default_port: int, +) -> None: + """Register all connection-related tools.""" + + @mcp.tool( + description=( + "Connect to the robot by setting the IP/port. This tool also tests connectivity to confirm that the robot is reachable and the port is open." + ), + annotations=ToolAnnotations( + title="Connect to Robot", + destructiveHint=False, + ), + ) + def connect_to_robot( + ip: str = default_ip, + port: Union[int, str] = default_port, + ping_timeout: float = 2.0, + port_timeout: float = 2.0, + ) -> dict: + """ + Connect to a robot by setting the IP and port for the WebSocket connection, then testing connectivity. + + Args: + ip (str): The IP address of the rosbridge server. + port (int): The port number of the rosbridge server. + ping_timeout (float): Timeout for ping in seconds. Default = 2.0. + port_timeout (float): Timeout for port check in seconds. Default = 2.0. + + Returns: + dict: Connection status with ping and port check results. + """ + # Set default values if None + actual_ip = str(ip).strip() if ip else default_ip + actual_port = int(port) if port else default_port + + # Set the IP and port + ws_manager.set_ip(actual_ip, actual_port) + + # Test connectivity + ping_result = ping_ip_and_port(actual_ip, actual_port, ping_timeout, port_timeout) + + # Detect ROS version and cache rosapi type resolver + detection_warning = None + if ping_result.get("port_check", {}).get("open"): + try: + detect_rosapi_types(ws_manager) + except Exception as e: + detection_warning = ( + f"ROS version detection failed: {e}. " + "Tools will assume ROS 2 type format (rosapi_msgs/srv/*)." + ) + + # Combine the results + result: dict[str, Any] = { + "message": f"WebSocket IP set to {actual_ip}:{actual_port}", + "connectivity_test": ping_result, + } + if detection_warning: + result["warning"] = detection_warning + return result + + @mcp.tool( + description=( + "Ping one or more robot IP addresses and check if specific ports are open.\n" + "A successful ping to the IP but not the port can indicate that ROSbridge is not running.\n" + "Example:\n" + "ping_robots(targets=[{'ip': '192.168.1.100', 'port': 9090}, {'ip': '192.168.1.101', 'port': 9090}])" + ), + annotations=ToolAnnotations( + title="Ping Robots", + readOnlyHint=True, + ), + ) + def ping_robots( + targets: list[dict] = None, # type: ignore[assignment] # See issue #140 + ping_timeout: float = 2.0, + port_timeout: float = 2.0, + ) -> dict: + """ + Ping one or more IP addresses and check if specific ports are open. + + Args: + targets (list[dict]): List of target dictionaries, each containing: + - ip (str): The IP address to ping (e.g., '192.168.1.100') + - port (int): The port number to check (e.g., 9090) + ping_timeout (float): Timeout for ping in seconds. Default = 2.0. + port_timeout (float): Timeout for port check in seconds. Default = 2.0. + + Returns: + dict: Contains a 'results' list with ping and port check results for each target. + """ + # Set default value if targets is None + if targets is None: + targets = [{"ip": "127.0.0.1", "port": 9090}] + + # Validate targets is a non-empty list + if not isinstance(targets, list): + return {"error": "targets must be a list of dictionaries", "results": []} + + if len(targets) == 0: + return {"error": "targets list cannot be empty", "results": []} + + # Pre-allocate results list to preserve input order + results: list[Any] = [None] * len(targets) + + # Phase 1: Validate all targets and collect valid ones with their indices + valid_targets = [] # List of (index, ip, port) tuples + + for i, target in enumerate(targets): + # Validate target is a dictionary + if not isinstance(target, dict): + results[i] = { + "ip": None, + "port": None, + "error": f"Target at index {i} is not a dictionary", + "ping": {"success": False, "error": "Invalid target format"}, + "port_check": {"open": False, "error": "Invalid target format"}, + "overall_status": "Invalid target format", + } + continue + + # Validate required keys exist + if "ip" not in target or "port" not in target: + results[i] = { + "ip": target.get("ip", None), + "port": target.get("port", None), + "error": f"Target at index {i} missing required keys 'ip' or 'port'", + "ping": {"success": False, "error": "Missing required keys"}, + "port_check": {"open": False, "error": "Missing required keys"}, + "overall_status": "Invalid target format", + } + continue + + # Validate ip is a string and port is an integer + ip = target["ip"] + port = target["port"] + + if not isinstance(ip, str): + results[i] = { + "ip": str(ip), + "port": port, + "error": f"Target at index {i}: 'ip' must be a string", + "ping": {"success": False, "error": "Invalid IP type"}, + "port_check": {"open": False, "error": "Invalid IP type"}, + "overall_status": "Invalid target format", + } + continue + + if not isinstance(port, int): + try: + port = int(port) + except (ValueError, TypeError): + results[i] = { + "ip": ip, + "port": port, + "error": f"Target at index {i}: 'port' must be an integer", + "ping": {"success": False, "error": "Invalid port type"}, + "port_check": {"open": False, "error": "Invalid port type"}, + "overall_status": "Invalid target format", + } + continue + + # Target is valid, add to list for parallel execution + valid_targets.append((i, ip, port)) + + # Phase 2: Execute pings in parallel for valid targets + if valid_targets: + max_workers = min(len(valid_targets), 20) + with ThreadPoolExecutor(max_workers=max_workers) as executor: + # Submit all ping tasks and create mapping + future_to_info = {} + for idx, ip, port in valid_targets: + future = executor.submit(ping_ip_and_port, ip, port, ping_timeout, port_timeout) + future_to_info[future] = (idx, ip, port) + + # Collect results as they complete + for future in as_completed(future_to_info): + idx, ip, port = future_to_info[future] + try: + results[idx] = future.result() + except Exception as e: + # Handle exceptions from individual ping operations + results[idx] = { + "ip": ip, + "port": port, + "error": f"Exception during ping: {str(e)}", + "ping": {"success": False, "error": f"Exception: {str(e)}"}, + "port_check": {"open": False, "error": f"Exception: {str(e)}"}, + "overall_status": "Error during ping operation", + } + + return {"results": results} diff --git a/ros_mcp/tools/images.py b/ros_mcp/tools/images.py new file mode 100644 index 0000000..acd173a --- /dev/null +++ b/ros_mcp/tools/images.py @@ -0,0 +1,89 @@ +"""Image tools for ROS MCP.""" + +import io +import os + +from fastmcp import FastMCP +from fastmcp.utilities.types import Image +from mcp.types import ImageContent, ToolAnnotations +from PIL import Image as PILImage + + +def convert_expects_image_hint(expects_image: str) -> bool | None: + """ + Convert string-based expects_image hint to boolean for internal use. + + Args: + expects_image (str): String hint about whether to expect image data + - "true": prioritize image parsing + - "false": skip image detection for faster processing + - "auto": auto-detect based on message fields (default) + - any other value: treated as "auto" + + Returns: + bool | None: Converted hint for parse_input function + - True: prioritize image parsing + - False: skip image detection + - None: auto-detect + """ + if expects_image == "true": + return True + elif expects_image == "false": + return False + else: # "auto" or any other value + return None + + +def _encode_image_to_imagecontent(image) -> ImageContent: + """ + Encodes a PIL Image to a format compatible with ImageContent. + + Args: + image (PIL.Image.Image): The image to encode. + + Returns: + ImageContent: JPEG-encoded image wrapped in an ImageContent object. + """ + buffer = io.BytesIO() + image.save(buffer, format="JPEG") + img_bytes = buffer.getvalue() + img_obj = Image(data=img_bytes, format="jpeg") + return img_obj.to_image_content() + + +def register_image_tools( + mcp: FastMCP, +) -> None: + """Register all image-related tools.""" + + @mcp.tool( + description=( + "View a previously saved image from disk.\n" + "Images are automatically saved when subscribing to image topics.\n" + "Use this tool to re-view a saved image without re-subscribing.\n" + ), + annotations=ToolAnnotations( + title="View Saved Image", + readOnlyHint=True, + ), + ) + def view_saved_image( + image_path: str = "./camera/received_image.jpeg", + ) -> ImageContent: # type: ignore # See issue #140 + """ + View a previously saved image from the specified path. + + Images are automatically saved to disk when subscribing to image topics + via subscribe_once() or subscribe_for_duration(). Use this tool to + re-view a saved image without re-subscribing. + + Args: + image_path (str): Path to the saved image file (default: "./camera/received_image.jpeg") + + Returns: + ImageContent: JPEG-encoded image wrapped in an ImageContent object, or error dict if file not found. + """ + if not os.path.exists(image_path): + return {"error": f"No image found at {image_path}"} # type: ignore[return-value] # See issue #140 + img = PILImage.open(image_path) + return _encode_image_to_imagecontent(img) diff --git a/ros_mcp/tools/nodes.py b/ros_mcp/tools/nodes.py new file mode 100644 index 0000000..fba2c5d --- /dev/null +++ b/ros_mcp/tools/nodes.py @@ -0,0 +1,129 @@ +"""Node tools for ROS MCP.""" + +from fastmcp import FastMCP +from mcp.types import ToolAnnotations + +from ros_mcp.utils.response import _check_response, _safe_get_values +from ros_mcp.utils.rosapi_types import rosapi_service, rosapi_type +from ros_mcp.utils.websocket import WebSocketManager + + +def register_node_tools( + mcp: FastMCP, + ws_manager: WebSocketManager, +) -> None: + """Register all node-related tools.""" + + @mcp.tool( + description=("Get list of all currently running ROS nodes.\nExample:\nget_nodes()"), + annotations=ToolAnnotations( + title="Get Nodes", + readOnlyHint=True, + ), + ) + def get_nodes() -> dict: + """ + Get list of all currently running ROS nodes. + + Returns: + dict: Contains list of all active nodes, + or a message string if no nodes are found. + """ + # rosbridge service call to get node list + message = { + "op": "call_service", + "service": rosapi_service("nodes"), + "type": rosapi_type("Nodes"), + "args": {}, + "id": "get_nodes_request_1", + } + + # Request node list from rosbridge + with ws_manager: + response = ws_manager.request(message) + + error = _check_response(response) + if error: + return error + + # Return node info if present + values = _safe_get_values(response) + if values is not None: + nodes = values.get("nodes", []) + return {"nodes": nodes, "node_count": len(nodes)} + return {"warning": "No nodes found"} + + @mcp.tool( + description=( + "Get detailed information about a specific node including its publishers, subscribers, and services.\n" + "Example:\n" + "get_node_details('/turtlesim')" + ), + annotations=ToolAnnotations( + title="Get Node Details", + readOnlyHint=True, + ), + ) + def get_node_details(node: str) -> dict: + """ + Get detailed information about a specific node including its publishers, subscribers, and services. + + Args: + node (str): The node name (e.g., '/turtlesim') + + Returns: + dict: Contains detailed node information including publishers, subscribers, and services, + or an error message if node doesn't exist. + """ + # Validate input + if not node or not node.strip(): + return {"error": "Node name cannot be empty"} + + result = { + "node": node, + "publishers": [], + "subscribers": [], + "services": [], + "publisher_count": 0, + "subscriber_count": 0, + "service_count": 0, + } + + # rosbridge service call to get node details + message = { + "op": "call_service", + "service": rosapi_service("node_details"), + "type": rosapi_type("NodeDetails"), + "args": {"node": node}, + "id": f"get_node_details_{node.replace('/', '_')}", + } + + # Request node details from rosbridge + with ws_manager: + response = ws_manager.request(message) + + error = _check_response(response) + if error: + return error + + # Extract data from the response + values = _safe_get_values(response) + if values is not None: + # Extract publishers, subscribers, and services from the response + # Note: rosapi uses "publishing" and "subscribing" field names + publishers = values.get("publishing", []) + subscribers = values.get("subscribing", []) + services = values.get("services", []) + + result["publishers"] = publishers + result["subscribers"] = subscribers + result["services"] = services + result["publisher_count"] = len(publishers) + result["subscriber_count"] = len(subscribers) + result["service_count"] = len(services) + + # Check if we got any data + if not result["publishers"] and not result["subscribers"] and not result["services"]: + return {"error": f"Node {node} not found or has no details available"} + + return result diff --git a/ros_mcp/tools/parameters.py b/ros_mcp/tools/parameters.py new file mode 100644 index 0000000..e4f9e5b --- /dev/null +++ b/ros_mcp/tools/parameters.py @@ -0,0 +1,678 @@ +"""Parameter tools for ROS MCP.""" + +from fastmcp import FastMCP +from mcp.types import ToolAnnotations + +from ros_mcp.utils.response import _extract_error +from ros_mcp.utils.rosapi_types import rosapi_service, rosapi_type +from ros_mcp.utils.websocket import WebSocketManager + + +def _safe_check_parameter_exists( + name: str, ws_manager: WebSocketManager +) -> tuple[bool, str, dict | None]: + """ + Safely check if a parameter exists using get_param (which doesn't crash rosapi_node). + Also returns the full response if the parameter exists, to avoid redundant calls. + + Returns: + tuple: (exists: bool, reason: str, response: dict | None) + """ + + def _is_empty_value(value: str) -> bool: + """Check if a parameter value indicates the parameter does not exist.""" + if not value: + return True + # Strip quotes (handles '""' which represents empty string) + stripped = value.strip('"').strip("'") + if not stripped: + return True + # ROS 1 rosbridge get_param returns the literal JSON value `null` (string "null") + # for missing parameters, with result=true. Treat as nonexistent. + return stripped == "null" + + message = { + "op": "call_service", + "service": rosapi_service("get_param"), + "type": rosapi_type("GetParam"), + "args": {"name": name}, + "id": f"check_param_exists_{name.replace('/', '_').replace(':', '_')}", + } + + try: + with ws_manager: + response = ws_manager.request(message) + + if not response: + return False, "No response from service", None + + # Check if parameter exists based on response + if response and "values" in response: + result_data = response["values"] + if isinstance(result_data, dict): + value = result_data.get("value", "") + # Check if value is effectively empty (handles '""' case for non-existent params) + if _is_empty_value(value): + reason = result_data.get("reason", "Parameter does not exist") + return False, reason, None + # Parameter exists and has a value + return True, "", response + elif response and "result" in response: + result_data = response["result"] + if isinstance(result_data, dict): + value = result_data.get("value", "") + # Check if value is effectively empty + if _is_empty_value(value): + reason = result_data.get("reason", "Parameter does not exist") + return False, reason, None + # Parameter exists and has a value + return True, "", response + elif result_data: + # Direct result (non-dict) - assume it exists + return True, "", response + + return False, "Unexpected response format", None + except Exception as e: + return False, f"Error checking parameter: {str(e)}", None + + +def register_parameter_tools( + mcp: FastMCP, + ws_manager: WebSocketManager, +) -> None: + """Register all parameter-related tools.""" + + @mcp.tool( + description=( + "Get a single ROS parameter value by name. Works only with ROS 2.\n" + "Example:\nget_parameter('/turtlesim:background_b')" + ), + annotations=ToolAnnotations( + title="Get Parameter", + readOnlyHint=True, + ), + ) + def get_parameter(name: str) -> dict: + """ + Get a single ROS parameter value by name. Works only with ROS 2. + + Args: + name (str): The parameter name (e.g., '/turtlesim:background_b') + + Returns: + dict: Contains parameter value and metadata, or error message if parameter not found. + """ + if not name or not name.strip(): + return {"error": "Parameter name cannot be empty"} + + # Check if parameter exists first to avoid unnecessary calls + # (This uses get_param which is safe, unlike has_param which crashes) + # The response is returned if parameter exists, so we can reuse it + exists, reason, response = _safe_check_parameter_exists(name, ws_manager) + if not exists: + return { + "name": name, + "value": "", + "successful": False, + "reason": reason or f"Parameter {name} does not exist", + "exists": False, + } + + # We already have the response from the existence check, so process it + # Handle string responses (fallback for malformed responses) + if isinstance(response, str): + return { + "name": name, + "value": "", + "successful": False, + "reason": f"Unexpected response format: {response}", + } + + # Process the response - parameter exists, so extract the value + if response and "values" in response: + result_data = response["values"] + if isinstance(result_data, dict): + value = result_data.get("value", "") + successful = result_data.get("successful", False) or bool(value) + reason = result_data.get("reason", "") + return { + "name": name, + "value": value, + "successful": successful, + "reason": reason, + } + elif response and "result" in response: + result_data = response["result"] + if isinstance(result_data, dict): + value = result_data.get("value", "") + successful = result_data.get("successful", False) or bool(value) + reason = result_data.get("reason", "") + return { + "name": name, + "value": value, + "successful": successful, + "reason": reason, + } + else: + # Direct value in result + return { + "name": name, + "value": str(result_data) if result_data is not None else "", + "successful": True, + "reason": "", + } + + # Fallback for unexpected response format + error_msg = ( + _extract_error(response) if response and isinstance(response, dict) else "No response" + ) + return {"error": f"Failed to get parameter {name}: {error_msg}"} + + @mcp.tool( + description=( + "Set a single ROS parameter value. Works only with ROS 2.\n" + "Example:\nset_parameter('/turtlesim:background_b', '255')" + ), + annotations=ToolAnnotations( + title="Set Parameter", + destructiveHint=True, + ), + ) + def set_parameter(name: str, value: str) -> dict: + """ + Set a single ROS parameter value. Works only with ROS 2. + + Args: + name (str): The parameter name (e.g., '/turtlesim:background_b') + value (str): The parameter value to set + + Returns: + dict: Contains success status and metadata, or error message if failed. + """ + if not name or not name.strip(): + return {"error": "Parameter name cannot be empty"} + + # Check if parameter exists first (set_param can create parameters, but checking + # helps avoid issues and provides better error messages) + exists, reason, _ = _safe_check_parameter_exists(name, ws_manager) + # Note: We continue even if parameter doesn't exist, as set_param can create it + + message = { + "op": "call_service", + "service": rosapi_service("set_param"), + "type": rosapi_type("SetParam"), + "args": {"name": name, "value": value}, + "id": f"set_param_{name.replace('/', '_').replace(':', '_')}", + } + + try: + with ws_manager: + response = ws_manager.request(message) + except Exception as e: + # Catch any exceptions to prevent crashes + return { + "name": name, + "value": value, + "successful": False, + "reason": f"Error setting parameter: {str(e)}", + } + + # Handle string responses (fallback for malformed responses) + if isinstance(response, str): + return { + "name": name, + "value": value, + "successful": False, + "reason": f"Unexpected response format: {response}", + } + + if response and "values" in response: + result_data = response["values"] + if isinstance(result_data, dict): + successful = result_data.get("successful", True) # Default to True if not specified + return { + "name": name, + "value": value, + "successful": successful, + "reason": result_data.get("reason", ""), + } + elif response and "result" in response: + result_data = response["result"] + if isinstance(result_data, dict): + successful = result_data.get("successful", True) # Default to True if not specified + return { + "name": name, + "value": value, + "successful": successful, + "reason": result_data.get("reason", ""), + } + else: + # Direct result (boolean or other) + return { + "name": name, + "value": value, + "successful": bool(result_data) if result_data is not None else True, + "reason": "", + } + + # Fallback for unexpected response format + error_msg = ( + _extract_error(response) if response and isinstance(response, dict) else "No response" + ) + return {"error": f"Failed to set parameter {name}: {error_msg}"} + + @mcp.tool( + description=( + "Check if a ROS parameter exists. Works only with ROS 2.\n" + "Example:\nhas_parameter('/turtlesim:background_b')" + ), + annotations=ToolAnnotations( + title="Has Parameter", + readOnlyHint=True, + ), + ) + def has_parameter(name: str) -> dict: + """ + Check if a ROS parameter exists. Works only with ROS 2. + + Args: + name (str): The parameter name (e.g., '/turtlesim:background_b') + + Returns: + dict: Contains existence status and metadata, or error message if failed. + + Note: This uses get_param internally (via _safe_check_parameter_exists) to avoid + crashes in rosapi_node when checking for non-existent parameters. + + Limitation: existence is inferred from the parameter's value because the + rosbridge ROS 1 get_param API has no separate "exists" signal. A parameter + whose value is an empty string or the literal string "null" is therefore + reported as not existing (false negative). + """ + if not name or not name.strip(): + return {"error": "Parameter name cannot be empty"} + + # Use safe check function directly to avoid rosapi_node crashes + # The /rosapi/has_param service crashes when the parameter doesn't exist + exists, reason, _ = _safe_check_parameter_exists(name, ws_manager) + + return { + "name": name, + "exists": exists, + "successful": True, + "reason": reason if not exists else "", + } + + @mcp.tool( + description=( + "Delete a ROS parameter. Works only with ROS 2.\n" + "Example:\ndelete_parameter('/turtlesim:background_b')" + ), + annotations=ToolAnnotations( + title="Delete Parameter", + destructiveHint=True, + ), + ) + def delete_parameter(name: str) -> dict: + """ + Delete a ROS parameter. Works only with ROS 2. + + Args: + name (str): The parameter name (e.g., '/turtlesim:background_b') + + Returns: + dict: Contains success status and metadata, or error message if failed. + """ + if not name or not name.strip(): + return {"error": "Parameter name cannot be empty"} + + # Check if parameter exists first to avoid unnecessary calls + exists, reason, _ = _safe_check_parameter_exists(name, ws_manager) + if not exists: + return { + "name": name, + "successful": False, + "reason": reason or f"Parameter {name} does not exist", + "exists": False, + } + + message = { + "op": "call_service", + "service": rosapi_service("delete_param"), + "type": rosapi_type("DeleteParam"), + "args": {"name": name}, + "id": f"delete_param_{name.replace('/', '_').replace(':', '_')}", + } + + try: + with ws_manager: + response = ws_manager.request(message) + except Exception as e: + # Catch any exceptions to prevent crashes + return { + "name": name, + "successful": False, + "reason": f"Error deleting parameter: {str(e)}", + } + + # Handle string responses (fallback for malformed responses) + if isinstance(response, str): + return { + "name": name, + "successful": False, + "reason": f"Unexpected response format: {response}", + } + + if response and "values" in response: + result_data = response["values"] + if isinstance(result_data, dict): + # ROS 1 rosapi delete_param returns {"values": {}, "result": true} on + # success, so when "successful" is absent inside values, fall back to + # the top-level "result" field. + if "successful" in result_data: + successful = bool(result_data["successful"]) + else: + successful = bool(response.get("result")) + reason = result_data.get("reason", "") + return { + "name": name, + "successful": successful, + "reason": reason, + } + elif response and "result" in response: + result_data = response["result"] + if isinstance(result_data, dict): + successful = result_data.get("successful", False) + reason = result_data.get("reason", "") + return { + "name": name, + "successful": successful, + "reason": reason, + } + elif result_data: + # Direct boolean result + return { + "name": name, + "successful": bool(result_data), + "reason": "", + } + + # Fallback for unexpected response format + error_msg = ( + _extract_error(response) if response and isinstance(response, dict) else "No response" + ) + return {"error": f"Failed to delete parameter {name}: {error_msg}"} + + @mcp.tool( + description=( + "Get list of all ROS parameter names for a specific node. Works only with ROS 2.\n" + "Example:\nget_parameters('cam2image')\nget_parameters('/cam2image')" + ), + annotations=ToolAnnotations( + title="Get Parameters", + readOnlyHint=True, + ), + ) + def get_parameters(node_name: str) -> dict: + """ + Get list of all ROS parameter names for a specific node. Works only with ROS 2. + + Args: + node_name (str): The node name (e.g., '/turtlesim') + + Returns: + dict: Contains list of all parameter names for the node, or error message if failed. + """ + if not node_name or not node_name.strip(): + return {"error": "Node name cannot be empty"} + + # Normalize node name (ensure it starts with /) + normalized_node = node_name.strip() + if not normalized_node.startswith("/"): + normalized_node = f"/{normalized_node}" + + # Remove trailing slash if present + if normalized_node.endswith("/") and len(normalized_node) > 1: + normalized_node = normalized_node[:-1] + + service_name = f"{normalized_node}/list_parameters" + + message = { + "op": "call_service", + "service": service_name, + "type": "rcl_interfaces/srv/ListParameters", + "args": {}, + "id": f"get_parameters_{normalized_node.replace('/', '_')}", + } + + try: + with ws_manager: + response = ws_manager.request(message) + except Exception as e: + # Catch any exceptions to prevent crashes + return {"error": f"Failed to get parameters for node {normalized_node}: {str(e)}"} + + # Handle string responses (fallback for malformed responses) + if isinstance(response, str): + return { + "error": f"Failed to get parameters for node {normalized_node}: Unexpected response format: {response}" + } + + # Check for timeout or connection errors + if not response: + return { + "error": f"Failed to get parameters for node {normalized_node}: No response or timeout from rosbridge" + } + + # Check for explicit error in response + if isinstance(response, dict) and "error" in response: + error_msg = response.get("error", "Service call failed") + return {"error": f"Failed to get parameters for node {normalized_node}: {error_msg}"} + + # Check for service response errors first + if response and "result" in response and not response["result"]: + # Service call failed - return error with details from values + error_msg = _extract_error(response) + return {"error": f"Failed to get parameters for node {normalized_node}: {error_msg}"} + + # Extract parameter names from response + names = [] + if response and "values" in response: + result_data = response["values"] + if isinstance(result_data, dict): + # Check for result.names structure + result_obj = result_data.get("result", {}) + if isinstance(result_obj, dict): + names = result_obj.get("names", []) + else: + # Try direct names field + names = result_data.get("names", []) + elif response and "result" in response: + result_data = response["result"] + if isinstance(result_data, dict): + # Check for result.names structure + result_obj = result_data.get("result", {}) + if isinstance(result_obj, dict): + names = result_obj.get("names", []) + else: + names = result_data.get("names", []) + + # Format parameter names with node prefix + formatted_names = [f"{normalized_node}:{name}" for name in names] + + return { + "node": normalized_node, + "parameters": formatted_names, + "parameter_count": len(formatted_names), + } + + @mcp.tool( + description=( + "Get comprehensive details about a specific ROS parameter including value, type, and metadata. " + "Works only with ROS 2.\n" + "Example:\n" + "get_parameter_details('/turtlesim:background_r')" + ), + annotations=ToolAnnotations( + title="Get Parameter Details", + readOnlyHint=True, + ), + ) + def get_parameter_details(name: str) -> dict: + """ + Get comprehensive details about a specific ROS parameter including value, type, and metadata. Works only with ROS 2. + + Args: + name (str): The parameter name (e.g., '/turtlesim:background_r') + + Returns: + dict: Contains detailed parameter information or error details. + """ + # Validate input + if not name or not name.strip(): + return {"error": "Parameter name cannot be empty"} + + # Helper to create error response + def _error_response(reason: str) -> dict: + return { + "name": name, + "value": "", + "type": "unknown", + "exists": False, + "description": "", + "node": name.split(":")[0] if ":" in name else "", + "parameter": name.split(":")[1] if ":" in name else name, + "reason": reason, + } + + # Check if parameter exists first + exists, reason, value_response = _safe_check_parameter_exists(name, ws_manager) + if not exists: + return _error_response(reason or f"Parameter {name} does not exist") + + # If we got a response from the existence check, use it directly + # Otherwise, make a new call (shouldn't happen, but safety check) + if value_response is None: + value_message = { + "op": "call_service", + "service": rosapi_service("get_param"), + "type": rosapi_type("GetParam"), + "args": {"name": name}, + "id": f"get_param_details_{name.replace('/', '_').replace(':', '_')}", + } + + try: + with ws_manager: + value_response = ws_manager.request(value_message) + except Exception as e: + return _error_response(f"Error getting parameter details: {str(e)}") + + # Handle string responses (fallback for malformed responses) + if isinstance(value_response, str): + return _error_response(f"Unexpected response format: {value_response}") + + if not value_response: + return _error_response("No response from service") + + # Handle different response formats + value_data = None + param_value = "" + param_successful = False + reason = "" + + if "values" in value_response: + value_data = value_response["values"] + if isinstance(value_data, dict): + param_value = value_data.get("value", "") + param_successful = value_data.get("successful", False) or bool(param_value) + reason = value_data.get("reason", "") + elif "result" in value_response: + result_data = value_response["result"] + if isinstance(result_data, dict): + value_data = result_data + param_value = result_data.get("value", "") + param_successful = result_data.get("successful", False) or bool(param_value) + reason = result_data.get("reason", "") + else: + # Direct value + param_value = str(result_data) if result_data is not None else "" + param_successful = bool(param_value) + + # Parameter should exist at this point (we checked earlier), but handle edge cases + if not param_successful and not param_value: + return _error_response(reason or f"Parameter {name} does not exist") + + # Get parameter type + type_message = { + "op": "call_service", + "service": rosapi_service("describe_parameters"), + "type": "rcl_interfaces/DescribeParameters", + "args": {"names": [name]}, + "id": f"describe_param_details_{name.replace('/', '_').replace(':', '_')}", + } + + try: + with ws_manager: + type_response = ws_manager.request(type_message) + except Exception: + # If describe_parameters fails, continue with type inference from value + type_response = None + + param_type = "unknown" + param_description = "" + + # Handle string responses (fallback for malformed responses) + if isinstance(type_response, str): + # Continue with type inference from value + pass + elif type_response is None: + # Service call failed, continue with type inference from value + pass + elif type_response and isinstance(type_response, dict): + if "values" in type_response: + result_data = type_response["values"] + if isinstance(result_data, dict): + descriptors = result_data.get("descriptors", []) + if descriptors and len(descriptors) > 0: + descriptor = descriptors[0] + param_type = descriptor.get("type", "unknown") + param_description = descriptor.get("description", "") + elif "result" in type_response and type_response["result"]: + result_data = type_response["result"] + if isinstance(result_data, dict): + descriptors = result_data.get("descriptors", []) + if descriptors and len(descriptors) > 0: + descriptor = descriptors[0] + param_type = descriptor.get("type", "unknown") + param_description = descriptor.get("description", "") + + # Fallback: Try to infer type from value + if param_type == "unknown" and param_value: + try: + clean_value = param_value.strip('"') + if clean_value.lower() in ["true", "false"]: + param_type = "bool" + elif clean_value.isdigit() or ( + clean_value.startswith("-") and clean_value[1:].isdigit() + ): + param_type = "int" + elif "." in clean_value and clean_value.replace(".", "").replace("-", "").isdigit(): + param_type = "float" + elif param_value.startswith('"') and param_value.endswith('"'): + param_type = "string" + elif clean_value == "": + param_type = "string" + else: + param_type = "string" + except Exception: + param_type = "string" + + return { + "name": name, + "value": param_value, + "type": param_type, + "exists": param_successful, + "description": param_description, + "node": name.split(":")[0] if ":" in name else "", + "parameter": name.split(":")[1] if ":" in name else name, + } diff --git a/ros_mcp/tools/robot_config.py b/ros_mcp/tools/robot_config.py new file mode 100644 index 0000000..477289b --- /dev/null +++ b/ros_mcp/tools/robot_config.py @@ -0,0 +1,97 @@ +"""Robot configuration tools for ROS MCP.""" + +from fastmcp import FastMCP +from mcp.types import ToolAnnotations + +from ros_mcp.utils.config_utils import get_verified_robot_spec_util, get_verified_robots_list_util +from ros_mcp.utils.rosapi_types import DetectionError, RosVersion, get_distro, get_ros_version +from ros_mcp.utils.websocket import WebSocketManager + + +def register_robot_config_tools(mcp: FastMCP, ws_manager: WebSocketManager) -> None: + """Register all robot configuration-related tools.""" + + @mcp.tool( + description=( + "Load specifications and usage context for a verified robot model. " + "ONLY use if the robot model is in the verified list (use get_verified_robots_list first to check). " + "Most robots won't have a spec - that's OK, connect directly using connect_to_robot instead." + ), + annotations=ToolAnnotations( + title="Get Verified Robot Spec", + readOnlyHint=True, + ), + ) + def get_verified_robot_spec(name: str) -> dict: + """ + Load pre-defined specifications and additional context for a verified robot model. + + This is OPTIONAL - only for a small set of pre-verified robot models stored in the repository. + Use get_verified_robots_list() first to check if a spec exists. + If no spec exists for your robot, simply use connect_to_robot() directly. + + Args: + name (str): The exact robot model name from the verified list. + + Returns: + dict: The robot specification with type, prompts, and additional context. + """ + robot_config = get_verified_robot_spec_util(name) + + if len(robot_config) > 1: + return { + "error": f"Multiple configurations found for robot '{name}'. Please specify a more precise name." + } + elif not robot_config: + return { + "error": f"No configuration found for robot '{name}'. Please check the name and try again. Or you can set the IP/port manually using the 'connect_to_robot' tool." + } + return {"robot_config": robot_config} + + @mcp.tool( + description=( + "List pre-verified robot models that have specification files with usage guidance available. " + "Use this to check if a robot model has additional context available before calling get_verified_robot_spec. " + "If your robot is not in this list, you can still connect to it directly using connect_to_robot." + ), + annotations=ToolAnnotations( + title="Get Verified Robots List", + readOnlyHint=True, + ), + ) + def get_verified_robots_list() -> dict: + """ + List all pre-verified robot models that have specification files available in the repository. + + This is a small curated list of robot models with pre-defined specifications. + If your robot model is not in this list, you can still connect to any ROS robot + using the connect_to_robot() tool directly. + + Returns: + dict: List of available verified robot model names and count. + """ + return get_verified_robots_list_util() + + @mcp.tool( + description="Detect the ROS version and distribution via rosbridge.", + annotations=ToolAnnotations( + title="Detect ROS Version", + readOnlyHint=True, + ), + ) + def detect_ros_version() -> dict: + """ + Detects the ROS version and distro via rosbridge WebSocket. + + Returns: + dict: {'version': '1' or '2', 'distro': ''} or error info. + """ + try: + version = get_ros_version() + distro = get_distro() + return { + "version": "1" if version == RosVersion.ROS1 else "2", + "distro": distro, + } + except DetectionError as e: + return {"error": f"Could not detect ROS version: {e}"} diff --git a/ros_mcp/tools/services.py b/ros_mcp/tools/services.py new file mode 100644 index 0000000..b13f0d6 --- /dev/null +++ b/ros_mcp/tools/services.py @@ -0,0 +1,327 @@ +"""Service tools for ROS MCP.""" + +from fastmcp import FastMCP +from mcp.types import ToolAnnotations + +from ros_mcp.utils.response import _check_response, _safe_get_values +from ros_mcp.utils.rosapi_types import rosapi_service, rosapi_type +from ros_mcp.utils.websocket import WebSocketManager + + +def register_service_tools( + mcp: FastMCP, + ws_manager: WebSocketManager, +) -> None: + """Register all service-related tools.""" + + @mcp.tool( + description=("Get list of all available ROS services.\nExample:\nget_services()"), + annotations=ToolAnnotations( + title="Get Services", + readOnlyHint=True, + ), + ) + def get_services() -> dict: + """ + Get list of all available ROS services. + + Returns: + dict: Contains list of all active services, + or a message string if no services are found. + """ + message = { + "op": "call_service", + "service": rosapi_service("services"), + "type": rosapi_type("Services"), + "args": {}, + "id": "get_services_request_1", + } + + with ws_manager: + response = ws_manager.request(message) + + error = _check_response(response) + if error: + return error + + values = _safe_get_values(response) + if values is not None: + services = values.get("services", []) + return {"services": services, "service_count": len(services)} + return {"warning": "No services found"} + + @mcp.tool( + description=( + "Get the service type for a specific service.\nExample:\nget_service_type('/rosapi/topics')" + ), + annotations=ToolAnnotations( + title="Get Service Type", + readOnlyHint=True, + ), + ) + def get_service_type(service: str) -> dict: + """ + Get the service type for a specific service. + + Args: + service (str): The service name (e.g., '/rosapi/topics') + + Returns: + dict: Contains the service type, + or an error message if service doesn't exist. + """ + if not service or not service.strip(): + return {"error": "Service name cannot be empty"} + + message = { + "op": "call_service", + "service": rosapi_service("service_type"), + "type": rosapi_type("ServiceType"), + "args": {"service": service}, + "id": f"get_service_type_request_{service.replace('/', '_')}", + } + + with ws_manager: + response = ws_manager.request(message) + + error = _check_response(response) + if error: + return error + + values = _safe_get_values(response) + if values is not None: + service_type = values.get("type", "") + if service_type: + return {"service": service, "type": service_type} + return {"error": f"Service {service} does not exist or has no type"} + return {"error": f"Failed to get type for service {service}"} + + @mcp.tool( + description=( + "Get complete service details including request/response structures and provider nodes.\n" + "Example:\n" + "get_service_details('/rosapi/topics')" + ), + annotations=ToolAnnotations( + title="Get Service Details", + readOnlyHint=True, + ), + ) + def get_service_details(service: str) -> dict: + """ + Get complete service details including request/response structures and provider nodes. + + Args: + service (str): The service name (e.g., '/rosapi/topics') + + Returns: + dict: Contains complete service definition with request and response structures, + provider nodes, and provider count. + """ + # Validate input + if not service or not service.strip(): + return {"error": "Service name cannot be empty"} + + result = { + "service": service, + "type": "", + "request": {}, + "response": {}, + "providers": [], + "provider_count": 0, + } + + with ws_manager: + # First get the service type + type_message = { + "op": "call_service", + "service": rosapi_service("service_type"), + "type": rosapi_type("ServiceType"), + "args": {"service": service}, + "id": f"get_service_type_{service.replace('/', '_')}", + } + + type_response = ws_manager.request(type_message) + error = _check_response(type_response) + if error: + return error + type_values = _safe_get_values(type_response) + if type_values is not None: + service_type = type_values.get("type", "") + if service_type: + result["type"] = service_type + else: + return {"error": f"Service {service} does not exist or has no type"} + else: + return {"error": f"Failed to get type for service {service}"} + + # Get request details + request_message = { + "op": "call_service", + "service": rosapi_service("service_request_details"), + "type": rosapi_type("ServiceRequestDetails"), + "args": {"type": result["type"]}, + "id": f"get_service_details_request_{result['type'].replace('/', '_')}", + } + + request_response = ws_manager.request(request_message) + request_values = _safe_get_values(request_response) + if request_values is not None: + typedefs = request_values.get("typedefs", []) + if typedefs: + for typedef in typedefs: + field_names = typedef.get("fieldnames", []) + field_types = typedef.get("fieldtypes", []) + fields = {} + for name, ftype in zip(field_names, field_types): + fields[name] = ftype + result["request"] = {"fields": fields, "field_count": len(fields)} + + # Get response details + response_message = { + "op": "call_service", + "service": rosapi_service("service_response_details"), + "type": rosapi_type("ServiceResponseDetails"), + "args": {"type": result["type"]}, + "id": f"get_service_details_response_{result['type'].replace('/', '_')}", + } + + response_response = ws_manager.request(response_message) + response_values = _safe_get_values(response_response) + if response_values is not None: + typedefs = response_values.get("typedefs", []) + if typedefs: + for typedef in typedefs: + field_names = typedef.get("fieldnames", []) + field_types = typedef.get("fieldtypes", []) + fields = {} + for name, ftype in zip(field_names, field_types): + fields[name] = ftype + result["response"] = {"fields": fields, "field_count": len(fields)} + + # Get service providers + provider_message = { + "op": "call_service", + "service": rosapi_service("service_node"), + "type": rosapi_type("ServiceNode"), + "args": {"service": service}, + "id": f"get_service_providers_request_{service.replace('/', '_')}", + } + + provider_response = ws_manager.request(provider_message) + providers = [] + + # Handle different response formats safely + provider_values = _safe_get_values(provider_response) + if provider_values is not None: + node = provider_values.get("node", "") + if node: + providers = [node] + elif ( + provider_response + and isinstance(provider_response, dict) + and isinstance(provider_response.get("result"), dict) + ): + node = provider_response["result"].get("node", "") + if node: + providers = [node] + + result["providers"] = providers + result["provider_count"] = len(providers) + + # Check if we got any data + if not result["request"] and not result["response"]: + return {"error": f"Service {service} not found or has no definition"} + + # Add note about field name format + result["note"] = ( + "Field names shown above are formatted for rosbridge (leading underscores removed). " + "Use these exact field names when calling call_service()." + ) + + return result + + @mcp.tool( + description=( + "Call a ROS service with specified request data.\n" + "Example:\n" + "call_service('/rosapi/topics', 'rosapi/Topics', {})\n" + "call_service('/slow_service', 'my_package/SlowService', {}, timeout=10.0) # Specify timeout only for slow services\n" + "\n" + "IMPORTANT: Field names in the request dict should match the field names shown by get_service_details(), " + "which are already formatted for rosbridge (without leading underscores). " + "For example, use {'topic': '/image'} not {'_topic': '/image'}." + ), + annotations=ToolAnnotations( + title="Call Service", + destructiveHint=True, + ), + ) + def call_service( + service_name: str, + service_type: str, + request: dict, + timeout: float = None, # type: ignore[assignment] # See issue #140 + ) -> dict: + """ + Call a ROS service with specified request data. + + Args: + service_name (str): The service name (e.g., '/rosapi/topics') + service_type (str): The service type (e.g., 'rosapi/Topics') + request (dict): Service request data as a dictionary + timeout (float): Timeout in seconds. If None, uses ws_manager.default_timeout. + + Returns: + dict: Contains the service response or error information. + """ + # Use ws_manager.default_timeout if timeout is None + if timeout is None: + timeout = ws_manager.default_timeout + + # rosbridge service call + message = { + "op": "call_service", + "service": service_name, + "type": service_type, + "args": request, + "id": f"call_service_request_{service_name.replace('/', '_')}", + } + + # Call the service through rosbridge + with ws_manager: + response = ws_manager.request(message, timeout=timeout) + + # Common error checks (no response, non-dict, ws_manager errors, result=false) + error = _check_response(response) + if error: + return { + "service": service_name, + "service_type": service_type, + "success": False, + **error, + } + + # Return service response + if response.get("op") == "service_response": + return { + "service": service_name, + "service_type": service_type, + "success": response.get("result", True), + "result": response.get("values", {}), + } + elif response.get("op") == "status" and response.get("level") == "error": + return { + "service": service_name, + "service_type": service_type, + "success": False, + "error": response.get("msg", "Unknown error"), + } + else: + return { + "service": service_name, + "service_type": service_type, + "success": False, + "error": "Unexpected response format", + "raw_response": response, + } diff --git a/ros_mcp/tools/topics.py b/ros_mcp/tools/topics.py new file mode 100644 index 0000000..354f0a0 --- /dev/null +++ b/ros_mcp/tools/topics.py @@ -0,0 +1,818 @@ +"""Topic tools for ROS MCP.""" + +import json +import os +import time + +from fastmcp import FastMCP +from fastmcp.tools.tool import ToolResult +from mcp.types import TextContent, ToolAnnotations +from PIL import Image as PILImage + +from ros_mcp.tools.images import _encode_image_to_imagecontent, convert_expects_image_hint +from ros_mcp.utils.response import _check_response, _safe_get_values +from ros_mcp.utils.rosapi_types import rosapi_service, rosapi_type +from ros_mcp.utils.websocket import WebSocketManager, parse_input + + +def register_topic_tools( + mcp: FastMCP, + ws_manager: WebSocketManager, +) -> None: + """Register all topic-related tools.""" + + @mcp.tool( + description=("Get list of all available ROS topics.\nExample:\nget_topics()"), + annotations=ToolAnnotations( + title="Get Topics", + readOnlyHint=True, + ), + ) + def get_topics() -> dict: + """ + Fetch available topics from the ROS bridge. + + Returns: + dict: Contains two lists - 'topics' and 'types', + or a message string if no topics are found. + """ + # rosbridge service call to get topic list + message = { + "op": "call_service", + "service": rosapi_service("topics"), + "type": rosapi_type("Topics"), + "args": {}, + "id": "get_topics_request_1", + } + + # Request topic list from rosbridge + with ws_manager: + response = ws_manager.request(message) + + error = _check_response(response) + if error: + return error + + # Return topic info if present + values = _safe_get_values(response) + if values is not None: + topics = values.get("topics", []) + types = values.get("types", []) + return {"topics": topics, "types": types, "topic_count": len(topics)} + return {"warning": "No topics found"} + + @mcp.tool( + description=( + "Get the message type for a specific topic.\nExample:\nget_topic_type('/cmd_vel')" + ), + annotations=ToolAnnotations( + title="Get Topic Type", + readOnlyHint=True, + ), + ) + def get_topic_type(topic: str) -> dict: + """ + Get the message type for a specific topic. + + Args: + topic (str): The topic name (e.g., '/cmd_vel') + + Returns: + dict: Contains the 'type' field with the message type, + or an error message if topic doesn't exist. + """ + # Validate input + if not topic or not topic.strip(): + return {"error": "Topic name cannot be empty"} + + # rosbridge service call to get topic type + message = { + "op": "call_service", + "service": rosapi_service("topic_type"), + "type": rosapi_type("TopicType"), + "args": {"topic": topic}, + "id": f"get_topic_type_request_{topic.replace('/', '_')}", + } + + # Request topic type from rosbridge + with ws_manager: + response = ws_manager.request(message) + + error = _check_response(response) + if error: + return error + + # Return topic type if present + values = _safe_get_values(response) + if values is not None: + topic_type = values.get("type", "") + if topic_type: + return {"topic": topic, "type": topic_type} + return {"error": f"Topic {topic} does not exist or has no type"} + return {"error": f"Failed to get type for topic {topic}"} + + @mcp.tool( + description=( + "Get detailed information about a specific topic including its type, publishers, and subscribers.\n" + "Example:\n" + "get_topic_details('/cmd_vel')" + ), + annotations=ToolAnnotations( + title="Get Topic Details", + readOnlyHint=True, + ), + ) + def get_topic_details(topic: str) -> dict: + """ + Get detailed information about a specific topic including its type, publishers, and subscribers. + + Args: + topic (str): The topic name (e.g., '/cmd_vel') + + Returns: + dict: Contains detailed topic information including type, publishers, and subscribers, + or an error message if topic doesn't exist. + """ + # Validate input + if not topic or not topic.strip(): + return {"error": "Topic name cannot be empty"} + + result = { + "topic": topic, + "type": "unknown", + "publishers": [], + "subscribers": [], + "publisher_count": 0, + "subscriber_count": 0, + } + + with ws_manager: + # Get topic type + type_message = { + "op": "call_service", + "service": rosapi_service("topic_type"), + "type": rosapi_type("TopicType"), + "args": {"topic": topic}, + "id": f"get_topic_type_{topic.replace('/', '_')}", + } + + type_response = ws_manager.request(type_message) + type_values = _safe_get_values(type_response) + if type_values is not None: + result["type"] = type_values.get("type", "unknown") + + # Get publishers for this topic + publishers_message = { + "op": "call_service", + "service": rosapi_service("publishers"), + "type": rosapi_type("Publishers"), + "args": {"topic": topic}, + "id": f"get_publishers_{topic.replace('/', '_')}", + } + + publishers_response = ws_manager.request(publishers_message) + pub_values = _safe_get_values(publishers_response) + if pub_values is not None: + result["publishers"] = pub_values.get("publishers", []) + + # Get subscribers for this topic + subscribers_message = { + "op": "call_service", + "service": rosapi_service("subscribers"), + "type": rosapi_type("Subscribers"), + "args": {"topic": topic}, + "id": f"get_subscribers_{topic.replace('/', '_')}", + } + + subscribers_response = ws_manager.request(subscribers_message) + sub_values = _safe_get_values(subscribers_response) + if sub_values is not None: + result["subscribers"] = sub_values.get("subscribers", []) + + result["publisher_count"] = len(result["publishers"]) + result["subscriber_count"] = len(result["subscribers"]) + + # Check if we got any data + if result["type"] == "unknown" and not result["publishers"] and not result["subscribers"]: + return {"error": f"Topic {topic} not found or has no details available"} + + return result + + @mcp.tool( + description=( + "Get the complete structure/definition of a message type.\n" + "Example:\n" + "get_message_details('geometry_msgs/Twist')" + ), + annotations=ToolAnnotations( + title="Get Message Details", + readOnlyHint=True, + ), + ) + def get_message_details(message_type: str) -> dict: + """ + Get the complete structure/definition of a message type. + + Args: + message_type (str): The message type (e.g., 'geometry_msgs/Twist') + + Returns: + dict: Contains the message structure with field names and types, + or an error message if the message type doesn't exist. + """ + # Validate input + if not message_type or not message_type.strip(): + return {"error": "Message type cannot be empty"} + + # rosbridge service call to get message details + message = { + "op": "call_service", + "service": rosapi_service("message_details"), + "type": rosapi_type("MessageDetails"), + "args": {"type": message_type}, + "id": f"get_message_details_request_{message_type.replace('/', '_')}", + } + + # Request message details from rosbridge + with ws_manager: + response = ws_manager.request(message) + + error = _check_response(response) + if error: + return error + + # Return message structure if present + values = _safe_get_values(response) + if values is not None: + typedefs = values.get("typedefs", []) + if typedefs: + # Parse the structure into a more readable format + structure = {} + for typedef in typedefs: + type_name = typedef.get("type", message_type) + field_names = typedef.get("fieldnames", []) + field_types = typedef.get("fieldtypes", []) + + fields = {} + for name, ftype in zip(field_names, field_types): + fields[name] = ftype + + structure[type_name] = {"fields": fields, "field_count": len(fields)} + + return {"message_type": message_type, "structure": structure} + else: + return {"error": f"Message type {message_type} not found or has no definition"} + else: + return {"error": f"Failed to get details for message type {message_type}"} + + @mcp.tool( + description=( + "Subscribe to a ROS topic and return the first message received.\n" + "Example:\n" + "subscribe_once(topic='/cmd_vel', msg_type='geometry_msgs/msg/TwistStamped')\n" + "subscribe_once(topic='/slow_topic', msg_type='my_package/SlowMsg', timeout=10.0) # Use longer timeout for slow topics\n" + "subscribe_once(topic='/high_rate_topic', msg_type='sensor_msgs/Image', timeout=5.0, queue_length=5, throttle_rate_ms=100) # Control message buffering and rate\n" + "subscribe_once(topic='/camera/image_raw', msg_type='sensor_msgs/Image', expects_image='true') # Hint that this is an image for faster processing\n" + "subscribe_once(topic='/point_cloud', msg_type='sensor_msgs/PointCloud2', expects_image='false') # Skip image detection for non-image data" + ), + annotations=ToolAnnotations( + title="Subscribe Once", + readOnlyHint=True, + ), + ) + def subscribe_once( + topic: str = "", + msg_type: str = "", + expects_image: str = "auto", + timeout: float = None, # type: ignore[assignment] # See issue #140 + queue_length: int = None, # type: ignore[assignment] # See issue #140 + throttle_rate_ms: int = None, # type: ignore[assignment] # See issue #140 + ): + """ + Subscribe to a given ROS topic via rosbridge and return the first message received. + + Args: + topic (str): The ROS topic name (e.g., "/cmd_vel", "/joint_states"). + msg_type (str): The ROS message type (e.g., "geometry_msgs/Twist"). + timeout (float): Timeout in seconds. If None, uses ws_manager.default_timeout. + queue_length (int): How many messages to buffer before dropping old ones. Must be โ‰ฅ 1. Default is 1. + throttle_rate_ms (int): Minimum interval between messages in milliseconds. Must be โ‰ฅ 0. Default is 0 (no throttling). + expects_image (str): Hint about whether to expect image data. + - "true": prioritize image parsing (use for sensor_msgs/Image topics) + - "false": skip image detection for faster processing (use for non-image topics) + - "auto": auto-detect based on message fields (default) + + Returns: + dict: + - {"msg": } if successful + - {"error": ""} if subscription or timeout fails + """ + # Validate critical args before attempting subscription + if not topic or not msg_type: + return {"error": "Missing required arguments: topic and msg_type must be provided."} + + # Set defaults for optional parameters + if timeout is None: + timeout = ws_manager.default_timeout + if queue_length is None: + queue_length = 1 # Default queue length + if throttle_rate_ms is None: + throttle_rate_ms = 0 # Default: no throttling + + # Validate and convert parameters (handle string inputs from MCP) + try: + timeout = float(timeout) + if timeout < 0: + return {"error": "timeout must be >= 0"} + except (ValueError, TypeError): + return {"error": "timeout must be a number"} + + try: + queue_length = int(queue_length) + if queue_length < 1: + return {"error": "queue_length must be an integer โ‰ฅ 1"} + except (ValueError, TypeError): + return {"error": "queue_length must be an integer"} + + try: + throttle_rate_ms = int(throttle_rate_ms) + if throttle_rate_ms < 0: + return {"error": "throttle_rate_ms must be an integer โ‰ฅ 0"} + except (ValueError, TypeError): + return {"error": "throttle_rate_ms must be an integer"} + + # Construct the rosbridge subscribe message + subscribe_msg: dict = { + "op": "subscribe", + "topic": topic, + "type": msg_type, + "queue_length": queue_length, + "throttle_rate": throttle_rate_ms, + } + + actual_timeout = timeout + + # Subscribe and wait for the first message + with ws_manager: + # Send subscription request + send_error = ws_manager.send(subscribe_msg) + if send_error: + return {"error": f"Failed to subscribe: {send_error}"} + + # Loop until we receive the first message or timeout + end_time = time.time() + actual_timeout + while time.time() < end_time: + response = ws_manager.receive(timeout=0.5) # non-blocking small timeout + if response is None: + continue # idle timeout: no frame this tick + + # Convert string hint to boolean for parse_input + expects_image_bool = convert_expects_image_hint(expects_image) + + # Parse input with expects_image hint + msg_data, was_parsed_as_image = parse_input(response, expects_image_bool) + + if not msg_data: + continue # parsing failed or empty + + # Check for status errors from rosbridge + if msg_data.get("op") == "status" and msg_data.get("level") == "error": + return {"error": f"Rosbridge error: {msg_data.get('msg', 'Unknown error')}"} + + # Check for the first published message + if msg_data.get("op") == "publish" and msg_data.get("topic") == topic: + # Unsubscribe before returning the message + unsubscribe_msg = {"op": "unsubscribe", "topic": topic} + ws_manager.send(unsubscribe_msg) + # Return appropriate message based on whether image was actually parsed + if was_parsed_as_image: + image_path = "./camera/received_image.jpeg" + if os.path.exists(image_path): + img = PILImage.open(image_path) + image_content = _encode_image_to_imagecontent(img) + return ToolResult( + content=[ + image_content, + TextContent( + type="text", + text=f"Image saved to {image_path}. Use view_saved_image() to re-view it.", + ), + ] + ) + return {"error": "Image received but file not found on disk"} + else: + return {"msg": msg_data.get("msg", {})} + + # Timeout - unsubscribe and return error + unsubscribe_msg = {"op": "unsubscribe", "topic": topic} + ws_manager.send(unsubscribe_msg) + return {"error": "Timeout waiting for message from topic"} + + @mcp.tool( + description=( + "Subscribe to a topic for a duration and collect messages.\n" + "Example:\n" + "subscribe_for_duration(topic='/cmd_vel', msg_type='geometry_msgs/msg/TwistStamped', duration=5, max_messages=10)\n" + "subscribe_for_duration(topic='/high_rate_topic', msg_type='sensor_msgs/Image', duration=10, queue_length=5, throttle_rate_ms=100) # Control message buffering and rate\n" + "subscribe_for_duration(topic='/camera/image_raw', msg_type='sensor_msgs/Image', duration=5, expects_image='true') # Hint that this is an image for faster processing\n" + "subscribe_for_duration(topic='/point_cloud', msg_type='sensor_msgs/PointCloud2', duration=5, expects_image='false') # Skip image detection for non-image data" + ), + annotations=ToolAnnotations( + title="Subscribe for Duration", + readOnlyHint=True, + ), + ) + def subscribe_for_duration( + topic: str = "", + msg_type: str = "", + duration: float = 5.0, + max_messages: int = 100, + queue_length: int = None, # type: ignore[assignment] # See issue #140 + throttle_rate_ms: int = None, # type: ignore[assignment] # See issue #140 + expects_image: str = "auto", + ): + """ + Subscribe to a ROS topic via rosbridge for a fixed duration and collect messages. + + Args: + topic (str): ROS topic name (e.g. "/cmd_vel", "/joint_states") + msg_type (str): ROS message type (e.g. "geometry_msgs/Twist") + duration (float): How long (seconds) to listen for messages + max_messages (int): Maximum number of messages to collect before stopping + queue_length (int): How many messages to buffer before dropping old ones. Must be โ‰ฅ 1. Default is 1. + throttle_rate_ms (int): Minimum interval between messages in milliseconds. Must be โ‰ฅ 0. Default is 0 (no throttling). + expects_image (str): Hint about whether to expect image data. + - "true": prioritize image parsing (use for sensor_msgs/Image topics) + - "false": skip image detection for faster processing (use for non-image topics) + - "auto": auto-detect based on message fields (default) + + Returns: + dict: + { + "topic": topic_name, + "collected_count": N, + "messages": [msg1, msg2, ...] + } + """ + # Validate critical args before subscribing + if not topic or not msg_type: + return {"error": "Missing required arguments: topic and msg_type must be provided."} + + # Set defaults for optional parameters + if queue_length is None: + queue_length = 1 # Default queue length + if throttle_rate_ms is None: + throttle_rate_ms = 0 # Default: no throttling + + # Validate and convert parameters + try: + duration = float(duration) + if duration < 0: + return {"error": "duration must be >= 0"} + except (ValueError, TypeError): + return {"error": "duration must be a number"} + + try: + max_messages = int(max_messages) + if max_messages < 1: + return {"error": "max_messages must be an integer โ‰ฅ 1"} + except (ValueError, TypeError): + return {"error": "max_messages must be an integer"} + + # Validate and convert parameters (handle string inputs from MCP) + try: + queue_length = int(queue_length) + if queue_length < 1: + return {"error": "queue_length must be an integer โ‰ฅ 1"} + except (ValueError, TypeError): + return {"error": "queue_length must be an integer"} + + try: + throttle_rate_ms = int(throttle_rate_ms) + if throttle_rate_ms < 0: + return {"error": "throttle_rate_ms must be an integer โ‰ฅ 0"} + except (ValueError, TypeError): + return {"error": "throttle_rate_ms must be an integer"} + + # Send subscription request + subscribe_msg: dict = { + "op": "subscribe", + "topic": topic, + "type": msg_type, + "queue_length": queue_length, + "throttle_rate": throttle_rate_ms, + } + + with ws_manager: + send_error = ws_manager.send(subscribe_msg) + if send_error: + return {"error": f"Failed to subscribe: {send_error}"} + + collected_messages = [] + status_errors = [] + end_time = time.time() + duration + + # Loop until duration expires or we hit max_messages + while time.time() < end_time and len(collected_messages) < max_messages: + response = ws_manager.receive(timeout=0.5) # non-blocking small timeout + if response is None: + continue # idle timeout: no frame this tick + + # Convert string hint to boolean for parse_input + expects_image_bool = convert_expects_image_hint(expects_image) + + # Parse input with expects_image hint + msg_data, was_parsed_as_image = parse_input(response, expects_image_bool) + + if not msg_data: + continue # parsing failed or empty + + # Check for status errors from rosbridge + if msg_data.get("op") == "status" and msg_data.get("level") == "error": + status_errors.append(msg_data.get("msg", "Unknown error")) + continue + + # Check for published messages matching our topic + if msg_data.get("op") == "publish" and msg_data.get("topic") == topic: + msg_index = len(collected_messages) + # Add message based on whether it was actually parsed as image + if was_parsed_as_image: + image_path = "./camera/received_image.jpeg" + if os.path.exists(image_path): + img = PILImage.open(image_path) + collected_messages.append(_encode_image_to_imagecontent(img)) + else: + collected_messages.append( + TextContent( + type="text", + text=f"[Message {msg_index}] Image received but file not found on disk", + ) + ) + else: + collected_messages.append( + TextContent( + type="text", + text=json.dumps(msg_data.get("msg", {})), + ) + ) + + # Unsubscribe when done + unsubscribe_msg = {"op": "unsubscribe", "topic": topic} + ws_manager.send(unsubscribe_msg) + + # Build summary as TextContent, then append all collected content blocks + summary = TextContent( + type="text", + text=json.dumps( + { + "topic": topic, + "collected_count": len(collected_messages), + "status_errors": status_errors, + } + ), + ) + return ToolResult(content=[summary] + collected_messages) + + @mcp.tool( + description=( + "Publish a sequence of messages to a ROS topic, each held for a given duration.\n" + "With rate_hz=0 (default): publishes each message once, then waits for the duration.\n" + "With rate_hz>0: publishes each message repeatedly at the given rate for the duration " + "(useful for controllers like diff_drive that need continuous commands).\n" + "Example (single publish with delay):\n" + "publish_for_durations(topic='/cmd_vel', msg_type='geometry_msgs/msg/Twist', " + "messages=[{'linear': {'x': 1.0}}, {'linear': {'x': 0.0}}], durations=[1, 2])\n" + "Example (continuous streaming at 10 Hz for 2 seconds):\n" + "publish_for_durations(topic='/cmd_vel', msg_type='geometry_msgs/msg/Twist', " + "messages=[{'angular': {'z': 0.5}}], durations=[2.0], rate_hz=10)" + ), + annotations=ToolAnnotations( + title="Publish for Durations", + destructiveHint=True, + ), + ) + def publish_for_durations( + topic: str = "", + msg_type: str = "", + messages: list[dict] = [], + durations: list[float] = [], + rate_hz: float = 0, + ) -> dict: + """ + Publish a sequence of messages to a given ROS topic for specified durations. + + Args: + topic (str): ROS topic name (e.g., "/cmd_vel") + msg_type (str): ROS message type (e.g., "geometry_msgs/msg/Twist") + messages (list[dict]): A list of message dictionaries (ROS-compatible payloads) + durations (list[float]): A list of durations (seconds) to hold each message + rate_hz (float): Publishing rate in Hz. 0 = publish once then wait (default). + >0 = publish repeatedly at this rate for the duration. + + Returns: + dict: + { + "success": True, + "published_count": , + "topic": topic, + "msg_type": msg_type, + "rate_hz": rate_hz + } + OR {"error": ""} if something failed + """ + # Neutralize the mutable default arguments + messages = list(messages) + durations = list(durations) + + # topic and msg_type really are required + if not topic or not msg_type: + return {"error": "Missing required arguments: topic and msg_type must be provided."} + + # Validate rate_hz + if rate_hz < 0: + return {"error": "rate_hz must be >= 0"} + if rate_hz > 100: + return {"error": "rate_hz must be <= 100"} + + # Empty is allowed: nothing to publish + if not messages and not durations: + return { + "success": True, + "published_count": 0, + "total_messages": 0, + "topic": topic, + "msg_type": msg_type, + "rate_hz": rate_hz, + "errors": [], + } + + # But one empty and the other not is an error + if len(messages) != len(durations): + return {"error": "messages and durations must have the same length"} + + # Optional: validate durations + if any(d < 0 for d in durations): + return {"error": "durations must be >= 0"} + + published_count = 0 + errors = [] + + with ws_manager: + advertise_msg = {"op": "advertise", "topic": topic, "type": msg_type} + send_error = ws_manager.send(advertise_msg) + if send_error: + return {"error": f"Failed to advertise topic: {send_error}"} + + try: + # publish loop + for i, (msg, duration) in enumerate(zip(messages, durations)): + publish_msg = {"op": "publish", "topic": topic, "msg": msg} + + if rate_hz > 0 and duration > 0: + # Streaming mode: publish repeatedly at rate_hz for duration + interval = 1.0 / rate_hz + end_time = time.time() + duration + next_time = time.time() + interval + while time.time() < end_time: + send_error = ws_manager.send(publish_msg) + if send_error: + errors.append(f"Message {i + 1}: {send_error}") + break + published_count += 1 + # Sleep until next target time to compensate for send overhead + sleep_time = next_time - time.time() + if sleep_time > 0: + time.sleep(sleep_time) + next_time += interval + else: + # Original mode: publish once, then wait + send_error = ws_manager.send(publish_msg) + if send_error: + errors.append(f"Message {i + 1}: {send_error}") + continue + + response = ws_manager.receive(timeout=1.0) + if response: + try: + msg_data = json.loads(response) + if ( + msg_data.get("op") == "status" + and msg_data.get("level") == "error" + ): + errors.append( + f"Message {i + 1}: {msg_data.get('msg', 'Unknown error')}" + ) + continue + except json.JSONDecodeError: + pass + + published_count += 1 + if duration: + time.sleep(duration) + + finally: + # always unadvertise + ws_manager.send({"op": "unadvertise", "topic": topic}) + + return { + "success": True, + "published_count": published_count, + "total_messages": len(messages), + "topic": topic, + "msg_type": msg_type, + "rate_hz": rate_hz, + "errors": errors, + } + + @mcp.tool( + description=( + "Publish a single message to a ROS topic.\n" + "Example:\n" + "publish_once(topic='/cmd_vel', msg_type='geometry_msgs/msg/TwistStamped', msg={'linear': {'x': 1.0}})" + ), + annotations=ToolAnnotations( + title="Publish Once", + destructiveHint=True, + ), + ) + def publish_once(topic: str = "", msg_type: str = "", msg: dict = {}) -> dict: + """ + Publish a single message to a ROS topic via rosbridge. + + Args: + topic (str): ROS topic name (e.g., "/cmd_vel") + msg_type (str): ROS message type (e.g., "geometry_msgs/msg/Twist") + msg (dict): Message payload as a dictionary + + Returns: + dict: + - {"success": True} if sent without errors + - {"error": ""} if connection/send failed + """ + # Neutralize the mutable default arguments + msg = dict(msg) + + # Validate ws_manager is available + if ws_manager is None: + return {"error": "WebSocket manager is not initialized"} + + # Validate required arguments + if not topic or not topic.strip(): + return {"error": "topic is required and cannot be empty"} + if not msg_type or not msg_type.strip(): + return {"error": "msg_type is required and cannot be empty"} + + # Validate msg is a dict + if not isinstance(msg, dict): + return {"error": f"Message must be a dict, got: {type(msg).__name__}"} + if msg == {}: + return {"error": "msg cannot be empty"} + + # Use proper advertise โ†’ publish โ†’ unadvertise pattern + with ws_manager: + # 1. Advertise the topic + advertise_msg = {"op": "advertise", "topic": topic, "type": msg_type} + send_error = ws_manager.send(advertise_msg) + if send_error: + return {"error": f"Failed to advertise topic: {send_error}"} + + # Check for advertise response/errors + response = ws_manager.receive(timeout=1.0) + if response: + try: + msg_data = json.loads(response) + if msg_data.get("op") == "status" and msg_data.get("level") == "error": + return { + "error": f"Advertise failed: {msg_data.get('msg', 'Unknown error')}" + } + except json.JSONDecodeError: + pass # Non-JSON response is usually fine for advertise + + # 2. Publish the message + publish_msg = {"op": "publish", "topic": topic, "msg": msg} + send_error = ws_manager.send(publish_msg) + if send_error: + # Try to unadvertise even if publish failed + ws_manager.send({"op": "unadvertise", "topic": topic}) + return {"error": f"Failed to publish message: {send_error}"} + + # Check for publish response/errors + response = ws_manager.receive(timeout=1.0) + if response: + try: + msg_data = json.loads(response) + if msg_data.get("op") == "status" and msg_data.get("level") == "error": + # Unadvertise before returning error + ws_manager.send({"op": "unadvertise", "topic": topic}) + return {"error": f"Publish failed: {msg_data.get('msg', 'Unknown error')}"} + except json.JSONDecodeError: + pass # Non-JSON response is usually fine for publish + + # 3. Unadvertise the topic + unadvertise_msg = {"op": "unadvertise", "topic": topic} + ws_manager.send(unadvertise_msg) + + return { + "success": True, + "note": "Message published using advertise โ†’ publish โ†’ unadvertise pattern", + } diff --git a/ros_mcp/utils/__init__.py b/ros_mcp/utils/__init__.py new file mode 100644 index 0000000..292207e --- /dev/null +++ b/ros_mcp/utils/__init__.py @@ -0,0 +1 @@ +# Utils package for ROS MCP Server diff --git a/ros_mcp/utils/config_utils.py b/ros_mcp/utils/config_utils.py new file mode 100644 index 0000000..b45e6d6 --- /dev/null +++ b/ros_mcp/utils/config_utils.py @@ -0,0 +1,88 @@ +from pathlib import Path + +import yaml + + +def load_robot_config(robot_name: str, specs_dir: str) -> dict: + """ + Load the robot configuration from a YAML file by robot name. + + Args: + robot_name (str): The name of the robot. + specs_dir (str): Directory containing robot specification files. + + Returns: + dict: The robot configuration. + + Raises: + FileNotFoundError: If the YAML file does not exist. + """ + file_path = Path(specs_dir) / f"{robot_name}.yaml" + + if not file_path.exists(): + raise FileNotFoundError(f"Robot config file not found: {file_path}") + + with file_path.open("r") as file: + return yaml.safe_load(file) or {} + + +def get_verified_robot_spec_util(name: str) -> dict: + """ + Get the verified robot specification in a more accessible format. + + Args: + name (str): The name of the robot. + + Returns: + dict: Parsed robot configuration with robot name as key. + """ + # Resolve relative to the project root (two levels up from utils) + specs_dir = Path(__file__).parent.parent.parent / "robot_specifications" + + name = name.replace(" ", "_") + config = load_robot_config(name, str(specs_dir)) + parsed_config = {} + + # Check if the loaded config has the required fields + if not config: + raise ValueError(f"No configuration found for robot '{name}'") + + # Check required fields + for field in ("type", "prompts"): + if field not in config or config[field] in (None, ""): + raise ValueError(f"Robot '{name}' is missing required field: {field}") + + # Create configuration with robot name as key + parsed_config[name] = {"type": config["type"], "prompts": config["prompts"]} + + return parsed_config + + +def get_verified_robots_list_util() -> dict: + """ + Get a list of all available robot specification files. + + Returns: + dict: List of available robot names that can be used with get_verified_robot_spec_util. + """ + # Resolve relative to the project root (two levels up from utils) + specs_path = Path(__file__).parent.parent.parent / "robot_specifications" + + if not specs_path.exists(): + return {"error": f"Robot specifications directory not found: {specs_path}"} + + try: + # Find all YAML files in the specifications directory + yaml_files = list(specs_path.glob("*.yaml")) + + if not yaml_files: + return {"error": "No robot specification files found"} + + # Extract robot names (file names without .yaml extension) + robot_names = [file.stem for file in yaml_files] + robot_names.sort() # Sort alphabetically for consistency + + return {"robot_specifications": robot_names, "count": len(robot_names)} + + except Exception as e: + return {"error": f"Failed to read robot specifications directory: {str(e)}"} diff --git a/ros_mcp/utils/network_utils.py b/ros_mcp/utils/network_utils.py new file mode 100644 index 0000000..e0500fb --- /dev/null +++ b/ros_mcp/utils/network_utils.py @@ -0,0 +1,161 @@ +import platform +import socket +import subprocess +from typing import Dict, Tuple + + +def _resolve_dns(hostname: str) -> Tuple[bool, str | None, str | None]: + """ + Resolve hostname to IP address. + + Args: + hostname: The hostname or IP address to resolve + + Returns: + Tuple of (success: bool, resolved_ip: str | None, error: str | None) + - If hostname is already an IP address, returns immediately with success=True + - If DNS resolution succeeds, returns (True, resolved_ip, None) + - If DNS resolution fails, returns (False, None, error_message) + """ + # Check if already an IP address + try: + socket.inet_aton(hostname) + return (True, hostname, None) # Already an IP, no DNS needed + except (socket.error, OSError): + pass # Not an IP, need DNS resolution + + # Try to resolve DNS + try: + addr_info = socket.getaddrinfo(hostname, None, socket.AF_INET, socket.SOCK_STREAM) + if addr_info: + # Extract IP address from the first result and ensure it's a string + resolved_ip = str(addr_info[0][4][0]) + return (True, resolved_ip, None) + else: + return (False, None, "DNS resolution returned no results") + except socket.gaierror as e: + return (False, None, f"DNS resolution error: {str(e)}") + except Exception as e: + return (False, None, f"DNS resolution error: {str(e)}") + + +def ping_ip_and_port( + ip: str, port: int, ping_timeout: float = 2.0, port_timeout: float = 2.0 +) -> Dict: + """ + Ping an IP address and check if a specific port is open. + + Args: + ip (str): The IP address or hostname to ping (e.g., '192.168.1.100' or 'duck7') + port (int): The port number to check (e.g., 9090) + ping_timeout (float): Timeout for ping in seconds. Default = 2.0. + port_timeout (float): Timeout for port check in seconds. Default = 2.0. + + Returns: + dict: Contains ping and port check results with detailed status information. + """ + result = { + "ip": ip, + "port": port, + "ping": {"success": False, "error": None, "response_time_ms": None}, + "port_check": {"open": False, "error": None}, + "overall_status": "unknown", + } + + # Step 0: DNS Resolution (do this FIRST) + dns_success, resolved_ip, dns_error = _resolve_dns(ip) + if not dns_success: + # Fail fast - return early with DNS error + result["ping"]["error"] = dns_error + result["port_check"]["error"] = dns_error + result["overall_status"] = ( + "DNS_resolution_failed. Check if the hostname is correct and DNS is configured properly." + ) + return result + + # Use resolved IP for subsequent operations + actual_ip = resolved_ip + + # Step 1: Ping the IP address + try: + # Use platform-specific ping command + if platform.system().lower() == "windows": + ping_cmd = ["ping", "-n", "1", "-w", str(int(ping_timeout * 1000)), actual_ip] + else: # Linux, macOS, etc. + ping_cmd = ["ping", "-c", "1", "-W", str(int(ping_timeout)), actual_ip] + + ping_result = subprocess.run( + ping_cmd, capture_output=True, text=True, timeout=ping_timeout + 1.0 + ) + + if ping_result.returncode == 0: + # Extract response time from ping output + output_lines = ping_result.stdout.split("\n") + for line in output_lines: + if "time=" in line or "time<" in line: + # Extract time value (format varies by OS) + if "time=" in line: + time_part = line.split("time=")[1].split()[0] + try: + result["ping"]["response_time_ms"] = float(time_part) + except ValueError: + result["ping"]["response_time_ms"] = None + break + + result["ping"]["success"] = True + else: + result["ping"]["error"] = f"Ping failed with return code {ping_result.returncode}" + + except subprocess.TimeoutExpired: + result["ping"]["error"] = f"Ping timeout after {ping_timeout} seconds" + except FileNotFoundError: + result["ping"]["error"] = "Ping command not found on this system" + except Exception as e: + result["ping"]["error"] = f"Ping error: {str(e)}" + + # Step 2: Check if port is open + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(port_timeout) + + port_result = sock.connect_ex((actual_ip, port)) + sock.close() + + if port_result == 0: + result["port_check"]["open"] = True + else: + result["port_check"]["error"] = ( + f"Port {port} is closed or unreachable (error code: {port_result})" + ) + + except socket.timeout: + result["port_check"]["error"] = ( + f"Port {port} connection timeout after {port_timeout} seconds" + ) + except socket.gaierror as e: + result["port_check"]["error"] = f"DNS resolution error: {str(e)}" + except Exception as e: + result["port_check"]["error"] = f"Port check error: {str(e)}" + + # Step 3: Determine overall status + ping_success = result["ping"]["success"] + port_open = result["port_check"]["open"] + + if ping_success and port_open: + result["overall_status"] = ( + "Fully_accessible. The robot is reachable and the port is open, indicating that we are likely able to connect to ROS" + ) + elif ping_success and not port_open: + result["overall_status"] = ( + "IP_reachable_port_closed. The robot is reachable but ROS_bridge is unreachable. Check if ROS_bridge is running as well as firewall settings." + ) + elif not ping_success and port_open: + result["overall_status"] = ( + "IP_unreachable_port_open. This is unusual." # Unusual but possible + ) + else: + result["overall_status"] = ( + "IP_unreachable. Check if the IP address is correct, the robot is powered on & connected to the network. Also check network and firewall settings." + ) + + return result diff --git a/ros_mcp/utils/response.py b/ros_mcp/utils/response.py new file mode 100644 index 0000000..d7e85d7 --- /dev/null +++ b/ros_mcp/utils/response.py @@ -0,0 +1,33 @@ +"""Helpers for safely handling rosbridge service responses.""" + +from typing import Optional + + +def _check_response(response: dict) -> Optional[dict]: + """Return an error dict if the response indicates failure, else None.""" + if not response or not isinstance(response, dict): + return {"error": "No response received from rosbridge"} + if "result" in response and not response["result"]: + error_msg = _extract_error(response) + return {"error": f"Service call failed: {error_msg}"} + return None + + +def _safe_get_values(response: dict) -> Optional[dict]: + """Extract the 'values' field from a response, returning None if absent or not a dict.""" + if not response or not isinstance(response, dict): + return None + values = response.get("values") + if isinstance(values, dict): + return values + return None + + +def _extract_error(response: dict) -> str: + """Extract a human-readable error message from a failed rosbridge response.""" + if not response or not isinstance(response, dict): + return "No response" + values = response.get("values", {}) + if isinstance(values, dict): + return values.get("message", "Service call failed") + return str(values) if values else "Service call failed" diff --git a/ros_mcp/utils/rosapi_types.py b/ros_mcp/utils/rosapi_types.py new file mode 100644 index 0000000..582bdad --- /dev/null +++ b/ros_mcp/utils/rosapi_types.py @@ -0,0 +1,244 @@ +"""Version-aware rosapi service type and path resolver. + +Detection strategy: +- ``get_ros_version`` service exists โ†’ **ROS 2** (this service is ROS 2-only) +- ``get_ros_version`` absent, ``get_param /rosdistro`` responds โ†’ **ROS 1** +- Neither responds โ†’ ``DetectionError`` + +Version determines the type format: +- ROS 1: ``rosapi/Topics`` +- ROS 2: ``rosapi_msgs/srv/Topics`` + +The service path prefix (``/rosapi/`` vs ``/rosapi_node/``) depends on the +rosapi node name in the launch file, not the ROS version. Both are probed +automatically. + +This module probes the running rosbridge *once* and caches the result. +""" + +from __future__ import annotations + +import enum +import logging +from typing import Any + +from ros_mcp.utils.websocket import WebSocketManager + +logger = logging.getLogger(__name__) + + +class RosVersion(enum.Enum): + """Detected ROS version.""" + + ROS1 = "ros1" + ROS2 = "ros2" + + +class DetectionError(RuntimeError): + """Raised when ROS version detection fails due to infrastructure problems.""" + + +# Type prefix per ROS version +_TYPE_PREFIX: dict[RosVersion, str] = { + RosVersion.ROS1: "rosapi", + RosVersion.ROS2: "rosapi_msgs/srv", +} + +# Known service path prefixes to probe (order matters: most common first) +_PREFIXES_TO_PROBE = ["/rosapi", "/rosapi_node"] + + +class RosapiTypeResolver: + """Resolves rosapi service type strings and paths based on ROS version.""" + + def __init__(self) -> None: + self._version: RosVersion | None = None + self._distro: str = "" + self._service_prefix: str = "/rosapi" + + def detect(self, ws_manager: WebSocketManager) -> None: + """Probe rosbridge to discover the ROS version and service prefix. + + Strategy: + 1. Try ``get_ros_version`` at each prefix. This service only exists + in ROS 2, so a successful response with ``version >= 2`` confirms ROS 2. + 2. If every prefix returned ``result: false`` (service not found), that + confirms ROS 1 โ€” the absence of ``get_ros_version`` is proof. + 3. If every prefix failed with an exception (network error, timeout), + raise ``DetectionError`` โ€” we cannot determine the version. + 4. On ROS 1, optionally get the distro name via ``get_param /rosdistro``. + """ + got_service_not_found = False + exceptions: list[str] = [] + + # Phase 1: try get_ros_version (ROS 2 only service) + with ws_manager: + for prefix in _PREFIXES_TO_PROBE: + try: + request: dict[str, Any] = { + "op": "call_service", + "id": f"rosapi_detect_{prefix.strip('/')}", + "service": f"{prefix}/get_ros_version", + "args": {}, + } + response = ws_manager.request(request) + + if not response or not isinstance(response, dict): + exceptions.append(f"{prefix}: empty or non-dict response") + continue + + if response.get("result") is False: + # Service not found โ€” this is a genuine signal + got_service_not_found = True + continue + + values = response.get("values") + if not isinstance(values, dict): + exceptions.append(f"{prefix}: values is not a dict: {values}") + continue + + # Determine ROS version from the response + raw_version = values.get("version") + if raw_version is not None and int(raw_version) >= 2: + self._version = RosVersion.ROS2 + self._distro = str(values.get("distro", "")).strip().lower() + self._service_prefix = prefix + logger.info( + "Detected ROS 2 distro '%s' โ†’ prefix=%s", + self._distro, + prefix, + ) + return + + # version < 2 or missing โ€” unexpected, treat as ROS 1 + got_service_not_found = True + + except Exception as e: + exceptions.append(f"{prefix}: {e}") + + # Phase 1 complete โ€” no ROS 2 found + if not got_service_not_found and exceptions: + # Every probe failed with exceptions โ€” we never reached rosbridge + raise DetectionError( + "Could not detect ROS version โ€” all probes failed with errors: " + + "; ".join(exceptions) + ) + + # Phase 2: confirmed ROS 1 (get_ros_version not found = ROS 2 service absent) + self._version = RosVersion.ROS1 + self._service_prefix = "/rosapi" + logger.info("get_ros_version not available โ€” confirmed ROS 1") + + # Optionally get distro name + try: + with ws_manager: + request = { + "op": "call_service", + "id": "rosapi_detect_ros1_distro", + "service": "/rosapi/get_param", + "args": {"name": "/rosdistro"}, + } + response = ws_manager.request(request) + + if response and isinstance(response, dict) and response.get("result") is not False: + values = response.get("values") + if values: + distro = values.get("value") if isinstance(values, dict) else values + self._distro = ( + str(distro).strip('"').replace("\\n", "").replace("\n", "").lower() + ) + logger.info("ROS 1 distro: '%s'", self._distro) + except Exception as e: + logger.warning("ROS 1 distro detection failed (non-fatal): %s", e) + + def _reset(self) -> None: + """Reset detection state. For testing only.""" + self._version = None + self._distro = "" + self._service_prefix = "/rosapi" + + @property + def version(self) -> RosVersion: + if self._version is None: + raise DetectionError("ROS version not detected โ€” call detect_rosapi_types() first") + return self._version + + @property + def distro(self) -> str: + return self._distro + + def get_type(self, short_name: str) -> str: + """Return the version-appropriate type string. + + Falls back to ROS 2 format (rosapi_msgs/srv/) when version is unknown. + """ + if self._version is None: + logger.warning( + "ROS version unknown โ€” falling back to ROS 2 type format for '%s'", + short_name, + ) + type_prefix = _TYPE_PREFIX[RosVersion.ROS2] + else: + type_prefix = _TYPE_PREFIX[self._version] + return f"{type_prefix}/{short_name}" + + def get_service(self, service_name: str) -> str: + """Return the discovered service path.""" + return f"{self._service_prefix}/{service_name}" + + +# Module-level singleton +_resolver = RosapiTypeResolver() + + +def detect_rosapi_types(ws_manager: WebSocketManager) -> None: + """Probe rosbridge and cache the correct type format. Call once at startup. + + Raises: + DetectionError: If rosbridge is unreachable (all probes failed with exceptions). + """ + _resolver.detect(ws_manager) + + +def _reset_resolver() -> None: + """Reset global resolver state. For testing only.""" + _resolver._reset() + + +def get_ros_version() -> RosVersion: + """Return the detected ROS version enum. + + Raises: + DetectionError: If detect_rosapi_types() was never called. + """ + return _resolver.version + + +def get_distro() -> str: + """Return the detected ROS distro name (e.g. 'noetic', 'humble'). + + May be empty on ROS 1 if distro detection failed. + """ + return _resolver.distro + + +def rosapi_type(short_name: str) -> str: + """Get the version-appropriate rosapi type string. + + Example:: + + rosapi_type("Services") # โ†’ "rosapi/Services" on ROS 1 + # โ†’ "rosapi_msgs/srv/Services" on ROS 2 + """ + return _resolver.get_type(short_name) + + +def rosapi_service(service_name: str) -> str: + """Get the version-appropriate rosapi service path. + + Example:: + + rosapi_service("nodes") # โ†’ "/rosapi/nodes" on Humble + # โ†’ "/rosapi_node/nodes" on Jazzy + """ + return _resolver.get_service(service_name) diff --git a/ros_mcp/utils/websocket.py b/ros_mcp/utils/websocket.py new file mode 100644 index 0000000..92c1e68 --- /dev/null +++ b/ros_mcp/utils/websocket.py @@ -0,0 +1,433 @@ +import base64 +import json +import os +import sys +import threading +from typing import Union + +import cv2 +import numpy as np +import websocket + + +def parse_json(raw: Union[str, bytes] | None) -> dict | None: + """ + Safely parse JSON from string or bytes. + + Args: + raw: JSON string, bytes, or None + + Returns: + Parsed dict if successful, None if raw is None, parsing fails, or result is not a dict + """ + if raw is None: + return None + if isinstance(raw, bytes): + raw = raw.decode("utf-8", errors="replace") + try: + result = json.loads(raw) + return result if isinstance(result, dict) else None + except (json.JSONDecodeError, TypeError): + return None + + +def is_image_like(msg_content: dict) -> bool: + """ + Check if a message looks like an image message by examining its fields. + + This checks for image-specific fields (width, height, encoding) in addition + to the data field to distinguish images from other messages that may contain + binary data (e.g., PointCloud2, ByteMultiArray). + + Args: + msg_content: The message content dictionary + + Returns: + bool: True if the message appears to be an image, False otherwise + """ + if not isinstance(msg_content, dict): + return False + + # Check for CompressedImage format (has 'data' and 'format' fields) + if "data" in msg_content and "format" in msg_content: + format_str = msg_content.get("format", "").lower() + if any(fmt in format_str for fmt in ["jpeg", "jpg", "png", "bmp", "compressed"]): + return True + + # Check for raw Image format (has 'data', 'width', 'height', 'encoding') + required_fields = {"data", "width", "height", "encoding"} + if not required_fields.issubset(msg_content.keys()): + return False + + # Validate field types + if not isinstance(msg_content.get("width"), int) or not isinstance( + msg_content.get("height"), int + ): + return False + + # Check for valid image encodings (sensor_msgs/Image standard encodings) + encoding = msg_content.get("encoding", "").lower() + valid_encodings = [ + "rgb8", + "rgba8", + "bgr8", + "bgra8", + "mono8", + "mono16", + "8uc1", + "8uc3", + "8uc4", + "16uc1", + "bayer", + "yuv", + ] + if not any(enc in encoding for enc in valid_encodings): + return False + + return True + + +def parse_image(raw: Union[str, bytes] | None) -> dict | None: + """ + Decode an image message (json with base64 data) and save it as JPEG. + + Args: + raw: JSON string, bytes, or None + + Returns: + Parsed dict if successful, None if raw is None, parsing fails, or result is not a dict + """ + # 1. Input validation + if raw is None: + return None + + # 2. Parse JSON and extract message + try: + result = json.loads(raw) + msg = result["msg"] + except (json.JSONDecodeError, KeyError): + print("[Image] Invalid JSON or missing 'msg' field.", file=sys.stderr) + return None + + # 3. Extract and validate required fields + data_b64 = msg.get("data") + if not data_b64: + print("[Image] Missing 'data' field in message.", file=sys.stderr) + return None + + # 4. Ensure output directory exists + os.makedirs("./camera", exist_ok=True) + + # 5. Determine image type and process accordingly + format = msg.get("format") + print(f"[Image] Format: {format}", file=sys.stderr) + + # 5a. Handle CompressedImage (already JPEG/PNG encoded) + if format and any(fmt in format.lower() for fmt in ["jpeg", "jpg", "png", "bmp", "compressed"]): + return _handle_compressed_image(data_b64, result) + + # 5b. Handle Raw Image (rgb8, bgr8, mono8, mono16, 16uc1) + height, width, encoding = msg.get("height"), msg.get("width"), msg.get("encoding") + if not all([height, width, encoding]): + print("[Image] Missing required fields for raw image.", file=sys.stderr) + return None + + return _handle_raw_image(data_b64, height, width, encoding, msg, result) + + +def _handle_compressed_image(data_b64: str, result: dict) -> dict | None: + """Handle compressed image data (JPEG/PNG already encoded).""" + path = "./camera/received_image.jpeg" + image_bytes = base64.b64decode(data_b64) + + with open(path, "wb") as f: + f.write(image_bytes) + + print(f"[Image] Saved CompressedImage to {path}", file=sys.stderr) + return result if isinstance(result, dict) else None + + +def _handle_raw_image( + data_b64: str, height: int, width: int, encoding: str, msg: dict, result: dict +) -> dict | None: + """Handle raw image data (needs decoding and conversion).""" + # Decode base64 to numpy array + image_bytes = base64.b64decode(data_b64) + + # Determine data type based on encoding + if encoding.lower() in ["mono16", "16uc1"]: + img_np = np.frombuffer(image_bytes, dtype=np.uint16) + else: + img_np = np.frombuffer(image_bytes, dtype=np.uint8) + + # Process based on encoding type + try: + img_cv = _decode_image_data(img_np, height, width, encoding, msg) + if img_cv is None: + return None + except ValueError as e: + print(f"[Image] Reshape error: {e}", file=sys.stderr) + return None + + # Save as JPEG with quality 95 + success = cv2.imwrite("./camera/received_image.jpeg", img_cv, [cv2.IMWRITE_JPEG_QUALITY, 95]) + if success: + print("[Image] Saved raw Image to ./camera/received_image.jpeg", file=sys.stderr) + return result if isinstance(result, dict) else None + else: + return None + + +def _decode_image_data( + img_np: np.ndarray, height: int, width: int, encoding: str, msg: dict +) -> np.ndarray | None: + """Decode image data based on encoding type.""" + # 8-bit encodings + if encoding == "rgb8": + img_cv = img_np.reshape((height, width, 3)) + img_cv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2BGR) + elif encoding == "bgr8": + img_cv = img_np.reshape((height, width, 3)) + elif encoding.lower() == "mono8": + img_cv = img_np.reshape((height, width)) + # 16-bit encodings + elif encoding.lower() in ["mono16", "16uc1"]: + img16 = img_np.reshape((height, width)) + # Handle big-endian byte order if needed + try: + if int(msg.get("is_bigendian", 0)) == 1: + img16 = img16.byteswap().newbyteorder() + except Exception: + # If field missing or not int-like, proceed without swapping + pass + + # Normalize 16-bit depth to 8-bit [0,255] for saving/preview + img_cv = cv2.normalize(img16, None, 0, 255, cv2.NORM_MINMAX, dtype=cv2.CV_8U) + else: + print(f"[Image] Unsupported encoding: {encoding}", file=sys.stderr) + return None + + return img_cv + + +def parse_input( + raw: Union[str, bytes] | None, expects_image: bool | None = None +) -> tuple[dict | None, bool]: + """ + Parse input data with optional image hint for optimized handling. + + Logic: + - expects_image=True: Try image parsing, fallback to JSON + - expects_image=False: Parse as JSON only (fastest) + - expects_image=None: Auto-detect using lightweight checks, then parse accordingly + + Args: + raw: JSON string, bytes, or None + expects_image: Optional hint about whether to expect image data + + Returns: + tuple: (parsed_data, was_parsed_as_image) + - parsed_data: Parsed dict if successful, None otherwise + - was_parsed_as_image: True if data was successfully parsed as image + """ + # 1. Input validation + if raw is None: + return None, False + + # 2. Parse as JSON first (always needed as fallback) + parsed_data = parse_json(raw) + if parsed_data is None: + return None, False + + # 3. Handle explicit hints + if expects_image is True: + return _handle_image_hint(raw, parsed_data) + elif expects_image is False: + return _handle_json_hint(parsed_data) + else: + return _handle_auto_detection(raw, parsed_data) + + +def _handle_image_hint(raw: Union[str, bytes], parsed_data: dict) -> tuple[dict | None, bool]: + """Handle explicit image hint - try image parsing first.""" + print("[Input] Hinted to parse as image", file=sys.stderr) + result = parse_image(raw) + if result is not None: + return result, True + return parsed_data, False + + +def _handle_json_hint(parsed_data: dict) -> tuple[dict | None, bool]: + """Handle explicit JSON hint - skip image parsing.""" + print("[Input] Hinted to parse as JSON", file=sys.stderr) + return parsed_data, False + + +def _handle_auto_detection(raw: Union[str, bytes], parsed_data: dict) -> tuple[dict | None, bool]: + """Handle auto-detection - check if message looks like an image.""" + print("[Input] Auto-detecting image", file=sys.stderr) + + # Check if this is a publish message that might contain image data + if parsed_data and isinstance(parsed_data, dict) and parsed_data.get("op") == "publish": + msg_content = parsed_data.get("msg", {}) + if is_image_like(msg_content): + # Try image parsing + result = parse_image(raw) + if result is not None: + return result, True + + # Return the already parsed JSON + return parsed_data, False + + +class WebSocketManager: + def __init__(self, ip: str, port: int, default_timeout: float = 2.0): + self.ip = ip + self.port = port + self.default_timeout = default_timeout + self.ws = None + self.lock = threading.RLock() + + def set_ip(self, ip: str, port: int): + """ + Set the IP and port for the WebSocket connection. + """ + self.ip = ip + self.port = port + print(f"[WebSocket] IP set to {self.ip}:{self.port}", file=sys.stderr) + + def connect(self) -> str | None: + """ + Attempt to establish a WebSocket connection. + + Returns: + None if successful, + or an error message string if connection failed. + """ + with self.lock: + if self.ws is None or not self.ws.connected: + try: + url = f"ws://{self.ip}:{self.port}" + self.ws = websocket.create_connection(url, timeout=self.default_timeout) + print( + f"[WebSocket] Connected ({self.default_timeout}s timeout)", file=sys.stderr + ) + return None # no error + except Exception as e: + error_msg = f"[WebSocket] Connection error: {e}" + print(error_msg, file=sys.stderr) + self.ws = None + return error_msg + return None # already connected, no error + + def send(self, message: dict) -> str | None: + """ + Send a JSON-serializable message over WebSocket. + + Returns: + None if successful, + or an error message string if send failed. + """ + with self.lock: + conn_error = self.connect() + if conn_error: + return conn_error # failed to connect + + if self.ws: + try: + json_msg = json.dumps(message) # ensure it's JSON-serializable + self.ws.send(json_msg) + return None # no error + except TypeError as e: + error_msg = f"[WebSocket] JSON serialization error: {e}" + print(error_msg, file=sys.stderr) + self.close() + return error_msg + except Exception as e: + error_msg = f"[WebSocket] Send error: {e}" + print(error_msg, file=sys.stderr) + self.close() + return error_msg + + return "[WebSocket] Not connected, send aborted." + + def receive(self, timeout: float | None = None) -> Union[str, bytes] | None: + """ + Receive a single message from rosbridge within the given timeout. + + Args: + timeout (float | None): Seconds to wait before timing out. + If None, uses the default timeout. + + Returns: + str | None: JSON string received from rosbridge, or None if timeout/error. + """ + with self.lock: + self.connect() + if self.ws: + try: + # Use default timeout if none specified + actual_timeout = timeout if timeout is not None else self.default_timeout + print(f"[WebSocket] Using timeout of {actual_timeout} seconds", file=sys.stderr) + # Temporarily set the receive timeout + self.ws.settimeout(actual_timeout) + raw = self.ws.recv() # rosbridge sends JSON as a string + return raw + except Exception as e: + print(f"[WebSocket] Receive error or timeout: {e}", file=sys.stderr) + self.close() + return None + return None + + def request(self, message: dict, timeout: float | None = None) -> dict: + """ + Send a request to Rosbridge and return the response. + + Args: + message (dict): The Rosbridge message dictionary to send. + timeout (float | None): Seconds to wait for a response. + If None, uses the default timeout. + + Returns: + dict: + - Parsed JSON response if successful. + - {"error": ""} if connection/send/receive fails. + - {"error": "invalid_json", "raw": } if decoding fails. + """ + # Attempt to send the message (connect() is called internally in send()) + send_error = self.send(message) + if send_error: + return {"error": send_error} + + # Attempt to receive a response (connect() is called internally in receive()) + response = self.receive(timeout=timeout) + if response is None: + return {"error": "no response or timeout from rosbridge"} + + # Attempt to parse response (auto-detect images, but services rarely return images) + parsed_response, _ = parse_input(response, expects_image=None) + if parsed_response is None: + print(f"[WebSocket] JSON decode error for response: {response}", file=sys.stderr) + return {"error": "invalid_json", "raw": response} + return parsed_response + + def close(self): + with self.lock: + if self.ws and self.ws.connected: + try: + self.ws.close() + print("[WebSocket] Closed", file=sys.stderr) + except Exception as e: + print(f"[WebSocket] Close error: {e}", file=sys.stderr) + finally: + self.ws = None + + def __enter__(self): + """Context manager entry - automatically connects.""" + # Don't connect here since we want to maintain the existing pattern + # where request() handles connection automatically + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit - automatically closes the connection.""" + self.close() diff --git a/server.json b/server.json new file mode 100644 index 0000000..87129e4 --- /dev/null +++ b/server.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.robotmcp/ros-mcp-server", + "description": "Connect AI models like Claude & ChatGPT with ROS robots using MCP", + "repository": { + "url": "https://github.com/robotmcp/ros-mcp-server", + "source": "github" + }, + "version": "3.1.0", + "packages": [ + { + "registryType": "pypi", + "registryBaseUrl": "https://pypi.org", + "identifier": "ros-mcp", + "version": "3.1.0", + "transport": { + "type": "stdio", + "command": "ros-mcp", + "args": [] + } + } + ] +} \ No newline at end of file diff --git a/server.py b/server.py new file mode 100644 index 0000000..d45f6e9 --- /dev/null +++ b/server.py @@ -0,0 +1,16 @@ +"""Entry point for ROS MCP Server (modular version). + +This module provides a simple entry point that imports and runs the main() function +from ros_mcp.main. This is the recommended way to run the server. + +Usage: + python server.py + python -m ros_mcp.main + +The modular version uses ros_mcp/main.py and ros_mcp/tools/. +""" + +from ros_mcp.main import main + +if __name__ == "__main__": + main() diff --git a/tests/installation/__init__.py b/tests/installation/__init__.py new file mode 100644 index 0000000..47ccde3 --- /dev/null +++ b/tests/installation/__init__.py @@ -0,0 +1,2 @@ +# Installation tests package +# These tests verify that installation methods documented in docs/install/installation.md work correctly. diff --git a/tests/installation/conftest.py b/tests/installation/conftest.py new file mode 100644 index 0000000..a3e7290 --- /dev/null +++ b/tests/installation/conftest.py @@ -0,0 +1,191 @@ +""" +Pytest configuration and fixtures for installation tests. + +These tests use Docker to verify installation methods in clean environments. +All tests install from git (current branch) to validate before publishing. +""" + +import subprocess +from pathlib import Path + +import pytest + +# Default repository URL for git-based installation +DEFAULT_REPO_URL = "https://github.com/robotmcp/ros-mcp-server.git" + + +def pytest_addoption(parser): + """Add command line options for installation tests.""" + parser.addoption( + "--branch", + action="store", + default=None, + help="Git branch/tag to test installation from (default: current branch)", + ) + parser.addoption( + "--repo-url", + action="store", + default=DEFAULT_REPO_URL, + help=f"Git repository URL (default: {DEFAULT_REPO_URL})", + ) + + +def pytest_configure(config): + """Register custom markers.""" + config.addinivalue_line( + "markers", "installation: marks tests as installation tests (may be slow)" + ) + + +def get_current_git_branch(repo_path: Path) -> str: + """Get the current git branch name from the repository.""" + try: + result = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + capture_output=True, + text=True, + cwd=repo_path, + timeout=10, + ) + if result.returncode == 0: + return result.stdout.strip() + except (subprocess.SubprocessError, FileNotFoundError): + pass + return "main" # Fallback to main if git command fails + + +@pytest.fixture(scope="module") +def repo_root() -> Path: + """Return path to repository root.""" + return Path(__file__).parent.parent.parent + + +@pytest.fixture(scope="module") +def docker_dir() -> Path: + """Return path to docker directory containing Dockerfiles.""" + return Path(__file__).parent / "docker" + + +@pytest.fixture(scope="module") +def git_branch(request, repo_root) -> str: + """Return the git branch to test installation from.""" + branch = request.config.getoption("--branch") + if branch is None: + branch = get_current_git_branch(repo_root) + return branch + + +@pytest.fixture(scope="module") +def repo_url(request) -> str: + """Return the git repository URL.""" + return request.config.getoption("--repo-url") + + +def docker_available() -> bool: + """Check if Docker is available and functional on the system.""" + try: + # First check if docker command exists + result = subprocess.run( + ["docker", "version"], + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode != 0: + return False + + # Also verify we can actually use Docker (not just have the command) + # This catches WSL2 cases where docker command exists but isn't connected + result = subprocess.run( + ["docker", "info"], + capture_output=True, + text=True, + timeout=10, + ) + return result.returncode == 0 + except (subprocess.SubprocessError, FileNotFoundError): + return False + + +@pytest.fixture(scope="module", autouse=True) +def require_docker(): + """Skip all tests if Docker is not available.""" + if not docker_available(): + pytest.skip("Docker is not available or not functional") + + +def build_docker_image( + dockerfile_path: Path, + context_path: Path, + tag: str | None = None, + build_args: dict | None = None, + timeout: int = 300, +) -> subprocess.CompletedProcess: + """ + Build a Docker image from a Dockerfile. + + Args: + dockerfile_path: Path to the Dockerfile + context_path: Path to the build context (usually repo root) + tag: Optional tag for the image + build_args: Optional dict of build arguments + timeout: Build timeout in seconds (default 5 minutes) + + Returns: + CompletedProcess with build output + """ + cmd = ["docker", "build", "-f", str(dockerfile_path)] + + if tag: + cmd.extend(["-t", tag]) + + if build_args: + for key, value in build_args.items(): + cmd.extend(["--build-arg", f"{key}={value}"]) + + cmd.append(str(context_path)) + + return subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout, + ) + + +def run_docker_container( + image: str, + command: str | None = None, + timeout: int = 60, +) -> subprocess.CompletedProcess: + """ + Run a Docker container. + + Args: + image: Image name or tag to run + command: Optional command to run in container + timeout: Run timeout in seconds + + Returns: + CompletedProcess with run output + """ + cmd = ["docker", "run", "--rm", image] + + if command: + cmd.extend(["sh", "-c", command]) + + return subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout, + ) + + +def cleanup_docker_image(tag: str) -> None: + """Remove a Docker image by tag.""" + subprocess.run( + ["docker", "rmi", "-f", tag], + capture_output=True, + timeout=30, + ) diff --git a/tests/installation/docker/Dockerfile.pip-git b/tests/installation/docker/Dockerfile.pip-git new file mode 100644 index 0000000..4a9d5a2 --- /dev/null +++ b/tests/installation/docker/Dockerfile.pip-git @@ -0,0 +1,29 @@ +# Test: pip install ros-mcp from git +# This Dockerfile verifies that pip installation from git works. +# +# Usage: docker build -f tests/installation/docker/Dockerfile.pip-git \ +# --build-arg REPO_URL=https://github.com/robotmcp/ros-mcp-server.git \ +# --build-arg BRANCH=main . + +FROM python:3.10-slim + +# Build arguments for git-based installation +ARG REPO_URL=https://github.com/robotmcp/ros-mcp-server.git +ARG BRANCH=main + +# Install git and OpenCV dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + git libgl1 libglib2.0-0 && \ + rm -rf /var/lib/apt/lists/* + +# Upgrade pip to latest +RUN pip install --upgrade pip + +# Install ros-mcp from git (specific branch) +RUN pip install "git+${REPO_URL}@${BRANCH}" + +# Verify the installation works +RUN ros-mcp --help + +# Verify the package is importable +RUN python -c "import ros_mcp; print('ros-mcp installed successfully')" diff --git a/tests/installation/docker/Dockerfile.pip-source b/tests/installation/docker/Dockerfile.pip-source new file mode 100644 index 0000000..9d3e122 --- /dev/null +++ b/tests/installation/docker/Dockerfile.pip-source @@ -0,0 +1,27 @@ +# Test: pip install ros-mcp from source (git clone + pip install .) +# This Dockerfile verifies that installation from cloned repository works. +# +# Usage: docker build -f tests/installation/docker/Dockerfile.pip-source . + +FROM python:3.10-slim + +# Install git and OpenCV dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + git libgl1 libglib2.0-0 && \ + rm -rf /var/lib/apt/lists/* + +# Copy the repository (build context should be repo root) +COPY . /ros-mcp-server +WORKDIR /ros-mcp-server + +# Upgrade pip +RUN pip install --upgrade pip + +# Install from source (as documented for development) +RUN pip install . + +# Verify the installation works +RUN ros-mcp --help + +# Verify the package is importable +RUN python -c "import ros_mcp; print('ros-mcp installed successfully')" diff --git a/tests/installation/docker/Dockerfile.uv-source b/tests/installation/docker/Dockerfile.uv-source new file mode 100644 index 0000000..8b22945 --- /dev/null +++ b/tests/installation/docker/Dockerfile.uv-source @@ -0,0 +1,30 @@ +# Test: uv sync from source (git clone + uv sync + uv run) +# This Dockerfile verifies development installation using uv works. +# +# Usage: docker build -f tests/installation/docker/Dockerfile.uv-source . + +FROM python:3.10-slim + +# Install curl for downloading uv and OpenCV dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl ca-certificates libgl1 libglib2.0-0 && \ + rm -rf /var/lib/apt/lists/* + +# Install uv (as documented) +RUN curl -LsSf https://astral.sh/uv/install.sh | sh + +# Add uv to PATH +ENV PATH="/root/.local/bin:$PATH" + +# Verify uv is installed +RUN uv --version + +# Copy the repository (build context should be repo root) +COPY . /ros-mcp-server +WORKDIR /ros-mcp-server + +# Sync dependencies using uv (development installation) +RUN uv sync + +# Verify ros-mcp runs via uv run +RUN uv run ros-mcp --help diff --git a/tests/installation/docker/Dockerfile.uvx b/tests/installation/docker/Dockerfile.uvx new file mode 100644 index 0000000..e86c1dd --- /dev/null +++ b/tests/installation/docker/Dockerfile.uvx @@ -0,0 +1,30 @@ +# Test: uvx ros-mcp installation from git +# This Dockerfile verifies the uvx installation method works with a git branch. +# +# Usage: docker build -f tests/installation/docker/Dockerfile.uvx \ +# --build-arg REPO_URL=https://github.com/robotmcp/ros-mcp-server.git \ +# --build-arg BRANCH=main . + +FROM python:3.10-slim + +# Build arguments for git-based installation +ARG REPO_URL=https://github.com/robotmcp/ros-mcp-server.git +ARG BRANCH=main + +# Install curl for downloading uv, git, and OpenCV dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl ca-certificates git libgl1 libglib2.0-0 && \ + rm -rf /var/lib/apt/lists/* + +# Install uv (as documented in installation.md) +RUN curl -LsSf https://astral.sh/uv/install.sh | sh + +# Add uv to PATH +ENV PATH="/root/.local/bin:$PATH" + +# Verify uv is installed +RUN uv --version + +# Run ros-mcp using uvx from git repository +# This downloads and runs ros-mcp from the specified branch +RUN uvx --from "git+${REPO_URL}@${BRANCH}" ros-mcp --help diff --git a/tests/installation/docker/Dockerfile.uvx-local b/tests/installation/docker/Dockerfile.uvx-local new file mode 100644 index 0000000..3ffa99f --- /dev/null +++ b/tests/installation/docker/Dockerfile.uvx-local @@ -0,0 +1,29 @@ +# Test: uvx ros-mcp installation from local repository +# This Dockerfile verifies that uvx can install and run ros-mcp from a local directory. +# +# Usage: docker build -f tests/installation/docker/Dockerfile.uvx-local . + +FROM python:3.10-slim + +# Install curl for downloading uv and OpenCV dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl ca-certificates libgl1 libglib2.0-0 && \ + rm -rf /var/lib/apt/lists/* + +# Install uv (as documented in installation.md) +RUN curl -LsSf https://astral.sh/uv/install.sh | sh + +# Add uv to PATH +ENV PATH="/root/.local/bin:$PATH" + +# Verify uv is installed +RUN uv --version + +# Copy the repository (build context should be repo root) +COPY . /ros-mcp-server +WORKDIR /ros-mcp-server + +# Run ros-mcp using uvx from local repository +# This installs and runs ros-mcp from the current directory +RUN uvx . ros-mcp --help + diff --git a/tests/installation/test_pip_install.py b/tests/installation/test_pip_install.py new file mode 100644 index 0000000..9853ec8 --- /dev/null +++ b/tests/installation/test_pip_install.py @@ -0,0 +1,119 @@ +""" +Tests for pip-based installation methods. + +These tests verify that pip install works from git and from local source. +All tests install from git/source to validate before publishing. +""" + +from pathlib import Path + +import pytest + +from .conftest import build_docker_image, cleanup_docker_image + + +@pytest.mark.installation +@pytest.mark.slow +def test_pip_install_from_git(repo_root: Path, docker_dir: Path, git_branch: str, repo_url: str): + """ + Test pip install ros-mcp from git in a clean container. + + This verifies that pip can install the package from a git branch. + """ + dockerfile = docker_dir / "Dockerfile.pip-git" + tag = "ros-mcp-test:pip-git" + + try: + result = build_docker_image( + dockerfile_path=dockerfile, + context_path=repo_root, + tag=tag, + build_args={ + "REPO_URL": repo_url, + "BRANCH": git_branch, + }, + timeout=300, # 5 minutes for package downloads + ) + + assert result.returncode == 0, ( + f"pip install from git failed (branch: {git_branch}):\n" + f"STDOUT:\n{result.stdout}\n" + f"STDERR:\n{result.stderr}" + ) + + finally: + cleanup_docker_image(tag) + + +@pytest.mark.installation +@pytest.mark.slow +def test_pip_install_from_source(repo_root: Path, docker_dir: Path): + """ + Test pip install . from cloned repository in a clean container. + + This verifies that developers can install the package from local source. + """ + dockerfile = docker_dir / "Dockerfile.pip-source" + tag = "ros-mcp-test:pip-source" + + try: + result = build_docker_image( + dockerfile_path=dockerfile, + context_path=repo_root, + tag=tag, + timeout=300, + ) + + assert result.returncode == 0, ( + f"pip install from source failed:\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) + + finally: + cleanup_docker_image(tag) + + +@pytest.mark.installation +@pytest.mark.slow +@pytest.mark.parametrize("python_version", ["3.10", "3.11", "3.12"]) +def test_pip_install_python_versions( + repo_root: Path, docker_dir: Path, git_branch: str, repo_url: str, python_version: str +): + """ + Test pip install works on multiple Python versions. + + This ensures compatibility across supported Python versions. + """ + # Read the Dockerfile and modify the base image + dockerfile_content = (docker_dir / "Dockerfile.pip-git").read_text() + dockerfile_content = dockerfile_content.replace( + "FROM python:3.10-slim", f"FROM python:{python_version}-slim" + ) + + # Write temporary Dockerfile + temp_dockerfile = docker_dir / f"Dockerfile.pip-git-{python_version}" + temp_dockerfile.write_text(dockerfile_content) + tag = f"ros-mcp-test:pip-py{python_version}" + + try: + result = build_docker_image( + dockerfile_path=temp_dockerfile, + context_path=repo_root, + tag=tag, + build_args={ + "REPO_URL": repo_url, + "BRANCH": git_branch, + }, + timeout=300, + ) + + assert result.returncode == 0, ( + f"pip install failed on Python {python_version} (branch: {git_branch}):\n" + f"STDOUT:\n{result.stdout}\n" + f"STDERR:\n{result.stderr}" + ) + + finally: + cleanup_docker_image(tag) + # Clean up temporary Dockerfile + if temp_dockerfile.exists(): + temp_dockerfile.unlink() diff --git a/tests/installation/test_source_install.py b/tests/installation/test_source_install.py new file mode 100644 index 0000000..91e517c --- /dev/null +++ b/tests/installation/test_source_install.py @@ -0,0 +1,120 @@ +""" +Tests for source-based installation using uv sync. + +These tests verify the development installation workflow. +Source tests use the local repository (current branch). +""" + +from pathlib import Path + +import pytest + +from .conftest import build_docker_image, cleanup_docker_image + + +@pytest.mark.installation +@pytest.mark.slow +def test_uv_source_install(repo_root: Path, docker_dir: Path): + """ + Test uv sync from cloned repository. + + This verifies the development workflow: + 1. git clone the repository + 2. uv sync + 3. uv run ros-mcp --help + """ + dockerfile = docker_dir / "Dockerfile.uv-source" + tag = "ros-mcp-test:uv-source" + + try: + result = build_docker_image( + dockerfile_path=dockerfile, + context_path=repo_root, + tag=tag, + timeout=300, + ) + + assert result.returncode == 0, ( + f"uv sync from source failed:\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) + + finally: + cleanup_docker_image(tag) + + +@pytest.mark.installation +@pytest.mark.slow +def test_uv_source_with_dev_dependencies(repo_root: Path, docker_dir: Path): + """ + Test uv sync with dev dependencies for development workflow. + + This verifies that developers can install with test dependencies. + """ + # Modify Dockerfile to include dev dependencies + dockerfile_content = (docker_dir / "Dockerfile.uv-source").read_text() + dockerfile_content = dockerfile_content.replace("RUN uv sync", "RUN uv sync --extra dev") + # Also verify pytest is available + dockerfile_content += ( + "\n# Verify pytest is available with dev dependencies\nRUN uv run pytest --version\n" + ) + + temp_dockerfile = docker_dir / "Dockerfile.uv-source-dev" + temp_dockerfile.write_text(dockerfile_content) + tag = "ros-mcp-test:uv-source-dev" + + try: + result = build_docker_image( + dockerfile_path=temp_dockerfile, + context_path=repo_root, + tag=tag, + timeout=300, + ) + + assert result.returncode == 0, ( + f"uv sync with dev dependencies failed:\n" + f"STDOUT:\n{result.stdout}\n" + f"STDERR:\n{result.stderr}" + ) + + finally: + cleanup_docker_image(tag) + if temp_dockerfile.exists(): + temp_dockerfile.unlink() + + +@pytest.mark.installation +@pytest.mark.slow +@pytest.mark.parametrize("python_version", ["3.10", "3.11", "3.12"]) +def test_uv_source_python_versions(repo_root: Path, docker_dir: Path, python_version: str): + """ + Test uv sync works on multiple Python versions. + + This ensures the development workflow works across supported Python versions. + """ + dockerfile_content = (docker_dir / "Dockerfile.uv-source").read_text() + dockerfile_content = dockerfile_content.replace( + "FROM python:3.10-slim", f"FROM python:{python_version}-slim" + ) + + temp_dockerfile = docker_dir / f"Dockerfile.uv-source-{python_version}" + temp_dockerfile.write_text(dockerfile_content) + tag = f"ros-mcp-test:uv-py{python_version}" + + try: + result = build_docker_image( + dockerfile_path=temp_dockerfile, + context_path=repo_root, + tag=tag, + timeout=300, + ) + + assert result.returncode == 0, ( + f"uv sync failed on Python {python_version}:\n" + f"STDOUT:\n{result.stdout}\n" + f"STDERR:\n{result.stderr}" + ) + + finally: + cleanup_docker_image(tag) + if temp_dockerfile.exists(): + temp_dockerfile.unlink() diff --git a/tests/installation/test_uvx_install.py b/tests/installation/test_uvx_install.py new file mode 100644 index 0000000..f4af027 --- /dev/null +++ b/tests/installation/test_uvx_install.py @@ -0,0 +1,76 @@ +""" +Tests for uvx-based installation method. + +This tests the primary documented installation method using uvx. +All tests install from git to validate before publishing. +""" + +from pathlib import Path + +import pytest + +from .conftest import build_docker_image, cleanup_docker_image + + +@pytest.mark.installation +@pytest.mark.slow +def test_uvx_install_from_git(repo_root: Path, docker_dir: Path, git_branch: str, repo_url: str): + """ + Test uvx ros-mcp installation from git. + + This verifies that uvx can install and run ros-mcp from a git branch. + Uses: uvx --from git+REPO_URL@BRANCH ros-mcp --help + """ + dockerfile = docker_dir / "Dockerfile.uvx" + tag = "ros-mcp-test:uvx-git" + + try: + result = build_docker_image( + dockerfile_path=dockerfile, + context_path=repo_root, + tag=tag, + build_args={ + "REPO_URL": repo_url, + "BRANCH": git_branch, + }, + timeout=300, # 5 minutes for uv and package downloads + ) + + assert result.returncode == 0, ( + f"uvx installation from git failed (branch: {git_branch}):\n" + f"STDOUT:\n{result.stdout}\n" + f"STDERR:\n{result.stderr}" + ) + + finally: + cleanup_docker_image(tag) + + +@pytest.mark.installation +@pytest.mark.slow +def test_uvx_install_from_local(repo_root: Path, docker_dir: Path): + """ + Test uvx ros-mcp installation from local repository. + + This verifies that uvx can install and run ros-mcp from a local directory. + Uses: uvx . ros-mcp --help + """ + dockerfile = docker_dir / "Dockerfile.uvx-local" + tag = "ros-mcp-test:uvx-local" + + try: + result = build_docker_image( + dockerfile_path=dockerfile, + context_path=repo_root, + tag=tag, + timeout=300, # 5 minutes for uv and package downloads + ) + + assert result.returncode == 0, ( + f"uvx installation from local failed:\n" + f"STDOUT:\n{result.stdout}\n" + f"STDERR:\n{result.stderr}" + ) + + finally: + cleanup_docker_image(tag) diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..1aba186 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,158 @@ +"""Integration test fixtures: Docker lifecycle and WebSocket access.""" + +import os +import socket +import subprocess +import time +import warnings +from pathlib import Path + +import pytest +from fastmcp import FastMCP + +from ros_mcp.resources import register_all_resources +from ros_mcp.tools import register_all_tools +from ros_mcp.utils.rosapi_types import detect_rosapi_types +from ros_mcp.utils.websocket import WebSocketManager + +COMPOSE_DIR = Path(__file__).parent +ROSBRIDGE_PORT = 9090 + +_DOCKERFILE_MAP = { + "melodic": "Dockerfile.ros1-melodic", + "noetic": "Dockerfile.ros1-noetic", + "humble": "Dockerfile.ros2-humble", + "jazzy": "Dockerfile.ros2-jazzy", +} + +_CONTAINER_NAME_MAP = { + "melodic": "integration-ros-melodic", + "noetic": "integration-ros-noetic", + "humble": "integration-ros2-humble", + "jazzy": "integration-ros2-jazzy", +} + + +def docker_available() -> bool: + try: + result = subprocess.run(["docker", "info"], capture_output=True, timeout=10) + return result.returncode == 0 + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + + +def _wait_for_rosbridge(port: int = ROSBRIDGE_PORT, timeout: float = 30) -> None: + """Poll rosbridge TCP port with a raw socket probe.""" + deadline = time.time() + timeout + while time.time() < deadline: + try: + with socket.create_connection(("127.0.0.1", port), timeout=2): + return + except OSError: + time.sleep(1) + raise TimeoutError(f"Rosbridge not ready after {timeout}s on port {port}") + + +def pytest_addoption(parser): + parser.addoption( + "--ros-distro", + default="humble", + choices=list(_DOCKERFILE_MAP.keys()), + help="ROS distro to test against (default: humble)", + ) + parser.addoption( + "--skip-compose", + action="store_true", + default=False, + help="Skip Docker compose up/down โ€” assume container is already running", + ) + + +def pytest_configure(config): + config.addinivalue_line("markers", "integration: integration tests requiring Docker + ROS") + + +@pytest.fixture(scope="session", autouse=True) +def require_docker(): + if not docker_available(): + pytest.skip("Docker is not available") + + +@pytest.fixture(scope="session") +def ros_distro(request): + """The selected ROS distro name.""" + return request.config.getoption("--ros-distro") + + +@pytest.fixture(scope="session") +def compose_up(require_docker, ros_distro, request): + """Start docker-compose with the selected ROS distro, yield, then tear down. + + If --skip-compose is set, assume the container is already running externally. + """ + if request.config.getoption("--skip-compose"): + yield + return + + dockerfile = _DOCKERFILE_MAP[ros_distro] + container_name = _CONTAINER_NAME_MAP[ros_distro] + compose_file = str(COMPOSE_DIR / "docker-compose.yml") + env = {**os.environ, "ROS_DOCKERFILE": dockerfile, "ROS_CONTAINER_NAME": container_name} + try: + result = subprocess.run( + ["docker", "compose", "-f", compose_file, "up", "--build", "-d", "--wait"], + timeout=300, + capture_output=True, + env=env, + ) + if result.returncode != 0: + stderr = result.stderr.decode() if result.stderr else "(no output)" + pytest.fail(f"docker compose up failed (exit {result.returncode}):\n{stderr}") + yield + finally: + down = subprocess.run( + ["docker", "compose", "-f", compose_file, "down", "--volumes", "--remove-orphans"], + timeout=60, + capture_output=True, + env=env, + ) + if down.returncode != 0: + warnings.warn(f"docker compose down failed (exit {down.returncode})") + + +@pytest.fixture(scope="session") +def ws(compose_up): + """WebSocketManager connected to the rosbridge container, with version detected.""" + ws_manager = WebSocketManager("127.0.0.1", ROSBRIDGE_PORT, default_timeout=5.0) + _wait_for_rosbridge(ROSBRIDGE_PORT, timeout=30) + detect_rosapi_types(ws_manager) + return ws_manager + + +@pytest.fixture(scope="session") +def tools(ws): + """MCP tool functions registered against the live ws_manager. + + Returns a dict mapping tool name to its callable, e.g.: + tools["connect_to_robot"]() โ†’ dict + tools["get_nodes"]() โ†’ dict + """ + mcp = FastMCP("test-ros-mcp") + register_all_tools(mcp, ws, rosbridge_ip="127.0.0.1", rosbridge_port=ROSBRIDGE_PORT) + return {name: tool.fn for name, tool in mcp._tool_manager._tools.items()} + + +@pytest.fixture(scope="session") +def resources(ws): + """MCP resource read-functions registered against the live ws_manager. + + Returns a dict mapping resource URI to its zero-arg callable. Each callable + returns a JSON string, e.g.: + json.loads(resources["ros-mcp://ros-metadata/all"]()) โ†’ dict + + Reaches into FastMCP's private resource registry, mirroring how the ``tools`` + fixture above reads ``_tool_manager._tools`` โ€” kept consistent on purpose. + """ + mcp = FastMCP("test-ros-mcp") + register_all_resources(mcp, ws) + return {uri: res.fn for uri, res in mcp._resource_manager._resources.items()} diff --git a/tests/integration/docker-compose.yml b/tests/integration/docker-compose.yml new file mode 100644 index 0000000..aef1e58 --- /dev/null +++ b/tests/integration/docker-compose.yml @@ -0,0 +1,14 @@ +services: + ros: + build: + context: ./docker + dockerfile: ${ROS_DOCKERFILE:-Dockerfile.ros2-humble} + container_name: ${ROS_CONTAINER_NAME:-ros-mcp-test} + ports: + - "9090:9090" + healthcheck: + test: ["CMD", "bash", "-c", "echo > /dev/tcp/localhost/9090"] + interval: 3s + timeout: 5s + retries: 20 + start_period: 15s diff --git a/tests/integration/docker/.gitattributes b/tests/integration/docker/.gitattributes new file mode 100644 index 0000000..ab1557d --- /dev/null +++ b/tests/integration/docker/.gitattributes @@ -0,0 +1 @@ +entrypoint-ros2.sh text eol=lf diff --git a/tests/integration/docker/Dockerfile.ros1-melodic b/tests/integration/docker/Dockerfile.ros1-melodic new file mode 100644 index 0000000..0e06ffd --- /dev/null +++ b/tests/integration/docker/Dockerfile.ros1-melodic @@ -0,0 +1,20 @@ +FROM ros:melodic-ros-base + +ENV ROS_DISTRO=melodic +ENV DEBIAN_FRONTEND=noninteractive +ENV QT_QPA_PLATFORM=offscreen +SHELL ["/bin/bash", "-c"] + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ros-melodic-rosbridge-suite \ + ros-melodic-turtlesim \ + ros-melodic-turtle-actionlib \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /ros_ws + +COPY ros1_launch.launch /ros_ws/ros1_launch.launch + +EXPOSE 9090 + +CMD ["roslaunch", "/ros_ws/ros1_launch.launch"] diff --git a/tests/integration/docker/Dockerfile.ros1-noetic b/tests/integration/docker/Dockerfile.ros1-noetic new file mode 100644 index 0000000..126af5e --- /dev/null +++ b/tests/integration/docker/Dockerfile.ros1-noetic @@ -0,0 +1,21 @@ +FROM ros:noetic-ros-base + +ENV ROS_DISTRO=noetic +ENV DEBIAN_FRONTEND=noninteractive +ENV QT_QPA_PLATFORM=offscreen +SHELL ["/bin/bash", "-c"] + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ros-noetic-rosbridge-suite \ + ros-noetic-turtlesim \ + ros-noetic-turtle-actionlib \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /ros_ws + +COPY ros1_launch.launch /ros_ws/ros1_launch.launch + +EXPOSE 9090 + +# Plain roslaunch (no --wait) auto-starts roscore if none is running. +CMD ["roslaunch", "/ros_ws/ros1_launch.launch"] diff --git a/tests/integration/docker/Dockerfile.ros2-humble b/tests/integration/docker/Dockerfile.ros2-humble new file mode 100644 index 0000000..0245971 --- /dev/null +++ b/tests/integration/docker/Dockerfile.ros2-humble @@ -0,0 +1,21 @@ +FROM ros:humble-ros-base + +ENV ROS_DISTRO=humble +ENV DEBIAN_FRONTEND=noninteractive +ENV QT_QPA_PLATFORM=offscreen +SHELL ["/bin/bash", "-c"] + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ros-humble-rosbridge-suite \ + ros-humble-turtlesim \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /ros2_ws + +COPY ros2_launch.py /ros2_ws/ros2_launch.py +COPY entrypoint-ros2.sh /ros2_ws/entrypoint.sh +RUN chmod +x /ros2_ws/entrypoint.sh + +EXPOSE 9090 + +CMD ["/ros2_ws/entrypoint.sh"] diff --git a/tests/integration/docker/Dockerfile.ros2-jazzy b/tests/integration/docker/Dockerfile.ros2-jazzy new file mode 100644 index 0000000..f6d571d --- /dev/null +++ b/tests/integration/docker/Dockerfile.ros2-jazzy @@ -0,0 +1,21 @@ +FROM ros:jazzy-ros-base + +ENV ROS_DISTRO=jazzy +ENV DEBIAN_FRONTEND=noninteractive +ENV QT_QPA_PLATFORM=offscreen +SHELL ["/bin/bash", "-c"] + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ros-jazzy-rosbridge-suite \ + ros-jazzy-turtlesim \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /ros2_ws + +COPY ros2_launch.py /ros2_ws/ros2_launch.py +COPY entrypoint-ros2.sh /ros2_ws/entrypoint.sh +RUN chmod +x /ros2_ws/entrypoint.sh + +EXPOSE 9090 + +CMD ["/ros2_ws/entrypoint.sh"] diff --git a/tests/integration/docker/entrypoint-ros2.sh b/tests/integration/docker/entrypoint-ros2.sh new file mode 100644 index 0000000..7cc1801 --- /dev/null +++ b/tests/integration/docker/entrypoint-ros2.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -e +source /opt/ros/${ROS_DISTRO}/setup.bash +exec ros2 launch /ros2_ws/ros2_launch.py diff --git a/tests/integration/docker/ros1_launch.launch b/tests/integration/docker/ros1_launch.launch new file mode 100644 index 0000000..a3b3bcd --- /dev/null +++ b/tests/integration/docker/ros1_launch.launch @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/tests/integration/docker/ros2_launch.py b/tests/integration/docker/ros2_launch.py new file mode 100644 index 0000000..09ea41b --- /dev/null +++ b/tests/integration/docker/ros2_launch.py @@ -0,0 +1,46 @@ +"""ROS2 launch file for integration tests: rosbridge + turtlesim + rosapi.""" + +from launch.actions import DeclareLaunchArgument +from launch.substitutions import LaunchConfiguration +from launch_ros.actions import Node + +from launch import LaunchDescription + + +def generate_launch_description(): + return LaunchDescription( + [ + DeclareLaunchArgument("port", default_value="9090"), + DeclareLaunchArgument("address", default_value=""), + Node( + package="turtlesim", + executable="turtlesim_node", + name="turtlesim", + output="screen", + ), + Node( + package="rosbridge_server", + executable="rosbridge_websocket", + name="rosbridge_websocket", + output="screen", + parameters=[ + { + "port": LaunchConfiguration("port"), + "address": LaunchConfiguration("address"), + "use_compression": False, + "max_message_size": 10000000, + "unregister_timeout": 10.0, + "service_timeout": 5, + "send_action_goals_in_new_thread": True, + "call_services_in_new_thread": True, + } + ], + ), + Node( + package="rosapi", + executable="rosapi_node", + name="rosapi", + output="screen", + ), + ] + ) diff --git a/tests/integration/scripts/run-all-cross-tests.sh b/tests/integration/scripts/run-all-cross-tests.sh new file mode 100755 index 0000000..c655abc --- /dev/null +++ b/tests/integration/scripts/run-all-cross-tests.sh @@ -0,0 +1,40 @@ +#!/bin/bash +# Run cross-tests: verify each distro is detected as the correct ROS version. +# Usage: ./tests/integration/scripts/run-all-cross-tests.sh + +set -e +cd "$(git rev-parse --show-toplevel)" + +PASSED=() +FAILED=() + +run_check() { + local distro="$1" + local expected="$2" + echo "" + echo "========================================" + echo " $distro โ†’ expecting $expected" + echo "========================================" + if ./tests/integration/scripts/run-cross-test.sh "$distro" "$expected"; then + PASSED+=("$distro=$expected") + else + FAILED+=("$distro=$expected") + fi +} + +# Correct expectations โ€” all should PASS +run_check melodic ROS1 +run_check noetic ROS1 +run_check humble ROS2 +run_check jazzy ROS2 + +echo "" +echo "========================================" +echo " RESULTS" +echo "========================================" +echo "Passed: ${PASSED[*]:-none}" +echo "Failed: ${FAILED[*]:-none}" + +if [ ${#FAILED[@]} -gt 0 ]; then + exit 1 +fi diff --git a/tests/integration/scripts/run-all-distros.sh b/tests/integration/scripts/run-all-distros.sh new file mode 100755 index 0000000..3c95959 --- /dev/null +++ b/tests/integration/scripts/run-all-distros.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# Run version detection tests against ALL supported distros. +# Usage: ./tests/integration/scripts/run-all-distros.sh + +set -e +cd "$(git rev-parse --show-toplevel)" + +DISTROS=(melodic noetic humble jazzy) +PASSED=() +FAILED=() + +for distro in "${DISTROS[@]}"; do + echo "" + echo "========================================" + echo " Testing: $distro" + echo "========================================" + echo "" + if ./tests/integration/scripts/run-detect-test.sh "$distro"; then + PASSED+=("$distro") + else + FAILED+=("$distro") + fi +done + +echo "" +echo "========================================" +echo " RESULTS" +echo "========================================" +echo "Passed: ${PASSED[*]:-none}" +echo "Failed: ${FAILED[*]:-none}" +echo "" + +if [ ${#FAILED[@]} -gt 0 ]; then + exit 1 +fi diff --git a/tests/integration/scripts/run-cross-test.sh b/tests/integration/scripts/run-cross-test.sh new file mode 100755 index 0000000..6d14d2d --- /dev/null +++ b/tests/integration/scripts/run-cross-test.sh @@ -0,0 +1,89 @@ +#!/bin/bash +# Cross-test: start one distro's container, tell pytest it's a DIFFERENT distro. +# The test should FAIL because the detector sees the real distro. +# +# Usage: ./tests/integration/scripts/run-cross-test.sh +# Example: ./tests/integration/scripts/run-cross-test.sh noetic humble +# โ†’ Starts Noetic, runs pytest with --ros-distro humble โ†’ should FAIL + +set -e +cd "$(git rev-parse --show-toplevel)" + +REAL_DISTRO="${1:?Usage: $0 }" +FAKE_DISTRO="${2:?Usage: $0 }" +COMPOSE="tests/integration/docker-compose.yml" + +if [ "$REAL_DISTRO" = "$FAKE_DISTRO" ]; then + EXPECT_PASS=true +else + EXPECT_PASS=false +fi + +declare -A DOCKERFILES=( + [melodic]="Dockerfile.ros1-melodic" + [noetic]="Dockerfile.ros1-noetic" + [humble]="Dockerfile.ros2-humble" + [jazzy]="Dockerfile.ros2-jazzy" +) + +declare -A CONTAINERS=( + [melodic]="integration-ros-melodic" + [noetic]="integration-ros-noetic" + [humble]="integration-ros2-humble" + [jazzy]="integration-ros2-jazzy" +) + +DOCKERFILE="${DOCKERFILES[$REAL_DISTRO]}" +CONTAINER="${CONTAINERS[$REAL_DISTRO]}" + +if [ -z "$DOCKERFILE" ]; then + echo "Unknown distro: $REAL_DISTRO (valid: melodic, noetic, humble, jazzy)" + exit 1 +fi + +echo "=== Cross-test: running $REAL_DISTRO, claiming $FAKE_DISTRO ===" +if $EXPECT_PASS; then + echo "Expected: pytest PASSES (same distro)" +else + echo "Expected: pytest FAILS on distro/version mismatch" +fi +echo "" + +# Start the REAL container +ROS_DOCKERFILE="$DOCKERFILE" ROS_CONTAINER_NAME="$CONTAINER" \ + docker compose -f "$COMPOSE" down --volumes 2>/dev/null || true +ROS_DOCKERFILE="$DOCKERFILE" ROS_CONTAINER_NAME="$CONTAINER" \ + docker compose -f "$COMPOSE" up --build -d --wait + +echo "" +echo "--- Running pytest --ros-distro $FAKE_DISTRO --skip-compose ---" +echo "" + +# Run pytest with the FAKE distro but skip compose (use the already-running REAL container) +if uv run pytest tests/integration/test_detect_version.py -v \ + --ros-distro "$FAKE_DISTRO" --skip-compose; then + PYTEST_PASSED=true +else + PYTEST_PASSED=false +fi + +echo "" +if $EXPECT_PASS && $PYTEST_PASSED; then + echo "PASS โ€” pytest passed as expected (same distro)" + EXIT=0 +elif $EXPECT_PASS && ! $PYTEST_PASSED; then + echo "!!! UNEXPECTED FAIL โ€” pytest should have passed for matching distro !!!" + EXIT=1 +elif ! $EXPECT_PASS && ! $PYTEST_PASSED; then + echo "PASS โ€” pytest failed as expected (detector saw $REAL_DISTRO, not $FAKE_DISTRO)" + EXIT=0 +else + echo "!!! UNEXPECTED PASS โ€” detector did not catch the mismatch !!!" + EXIT=1 +fi + +# Tear down +ROS_DOCKERFILE="$DOCKERFILE" ROS_CONTAINER_NAME="$CONTAINER" \ + docker compose -f "$COMPOSE" down --volumes 2>/dev/null || true + +exit $EXIT diff --git a/tests/integration/scripts/run-detect-cross-test.sh b/tests/integration/scripts/run-detect-cross-test.sh new file mode 100755 index 0000000..746a896 --- /dev/null +++ b/tests/integration/scripts/run-detect-cross-test.sh @@ -0,0 +1,89 @@ +#!/bin/bash +# Cross-test: start one distro's container, tell pytest it's a DIFFERENT distro. +# The test should FAIL because the detector sees the real distro. +# +# Usage: ./tests/integration/scripts/run-detect-cross-test.sh +# Example: ./tests/integration/scripts/run-detect-cross-test.sh noetic humble +# โ†’ Starts Noetic, runs pytest with --ros-distro humble โ†’ should FAIL + +set -e +cd "$(git rev-parse --show-toplevel)" + +REAL_DISTRO="${1:?Usage: $0 }" +FAKE_DISTRO="${2:?Usage: $0 }" +COMPOSE="tests/integration/docker-compose.yml" + +if [ "$REAL_DISTRO" = "$FAKE_DISTRO" ]; then + EXPECT_PASS=true +else + EXPECT_PASS=false +fi + +declare -A DOCKERFILES=( + [melodic]="Dockerfile.ros1-melodic" + [noetic]="Dockerfile.ros1-noetic" + [humble]="Dockerfile.ros2-humble" + [jazzy]="Dockerfile.ros2-jazzy" +) + +declare -A CONTAINERS=( + [melodic]="integration-ros-melodic" + [noetic]="integration-ros-noetic" + [humble]="integration-ros2-humble" + [jazzy]="integration-ros2-jazzy" +) + +DOCKERFILE="${DOCKERFILES[$REAL_DISTRO]}" +CONTAINER="${CONTAINERS[$REAL_DISTRO]}" + +if [ -z "$DOCKERFILE" ]; then + echo "Unknown distro: $REAL_DISTRO (valid: melodic, noetic, humble, jazzy)" + exit 1 +fi + +echo "=== Cross-test: running $REAL_DISTRO, claiming $FAKE_DISTRO ===" +if $EXPECT_PASS; then + echo "Expected: pytest PASSES (same distro)" +else + echo "Expected: pytest FAILS on distro/version mismatch" +fi +echo "" + +# Start the REAL container +ROS_DOCKERFILE="$DOCKERFILE" ROS_CONTAINER_NAME="$CONTAINER" \ + docker compose -f "$COMPOSE" down --volumes 2>/dev/null || true +ROS_DOCKERFILE="$DOCKERFILE" ROS_CONTAINER_NAME="$CONTAINER" \ + docker compose -f "$COMPOSE" up --build -d --wait + +echo "" +echo "--- Running pytest --ros-distro $FAKE_DISTRO --skip-compose ---" +echo "" + +# Run pytest with the FAKE distro but skip compose (use the already-running REAL container) +if uv run pytest tests/integration/test_detect_version.py -v \ + --ros-distro "$FAKE_DISTRO" --skip-compose; then + PYTEST_PASSED=true +else + PYTEST_PASSED=false +fi + +echo "" +if $EXPECT_PASS && $PYTEST_PASSED; then + echo "PASS โ€” pytest passed as expected (same distro)" + EXIT=0 +elif $EXPECT_PASS && ! $PYTEST_PASSED; then + echo "!!! UNEXPECTED FAIL โ€” pytest should have passed for matching distro !!!" + EXIT=1 +elif ! $EXPECT_PASS && ! $PYTEST_PASSED; then + echo "PASS โ€” pytest failed as expected (detector saw $REAL_DISTRO, not $FAKE_DISTRO)" + EXIT=0 +else + echo "!!! UNEXPECTED PASS โ€” detector did not catch the mismatch !!!" + EXIT=1 +fi + +# Tear down +ROS_DOCKERFILE="$DOCKERFILE" ROS_CONTAINER_NAME="$CONTAINER" \ + docker compose -f "$COMPOSE" down --volumes 2>/dev/null || true + +exit $EXIT diff --git a/tests/integration/scripts/run-detect-test.sh b/tests/integration/scripts/run-detect-test.sh new file mode 100755 index 0000000..689b36c --- /dev/null +++ b/tests/integration/scripts/run-detect-test.sh @@ -0,0 +1,63 @@ +#!/bin/bash +# Run the version detection test against a specific ROS distro. +# Usage: ./tests/integration/scripts/run-detect-test.sh +# Example: ./tests/integration/scripts/run-detect-test.sh noetic + +set -e +cd "$(git rev-parse --show-toplevel)" + +DISTRO="${1:?Usage: $0 }" +COMPOSE="tests/integration/docker-compose.yml" + +declare -A DOCKERFILES=( + [melodic]="Dockerfile.ros1-melodic" + [noetic]="Dockerfile.ros1-noetic" + [humble]="Dockerfile.ros2-humble" + [jazzy]="Dockerfile.ros2-jazzy" +) + +declare -A CONTAINERS=( + [melodic]="integration-ros-melodic" + [noetic]="integration-ros-noetic" + [humble]="integration-ros2-humble" + [jazzy]="integration-ros2-jazzy" +) + +DOCKERFILE="${DOCKERFILES[$DISTRO]}" +CONTAINER="${CONTAINERS[$DISTRO]}" + +if [ -z "$DOCKERFILE" ]; then + echo "Unknown distro: $DISTRO" + echo "Valid options: melodic, noetic, humble, jazzy" + exit 1 +fi + +echo "=== Testing $DISTRO ===" +echo "Dockerfile: $DOCKERFILE" +echo "Container: $CONTAINER" +echo "" + +# Tear down any previous container +ROS_DOCKERFILE="$DOCKERFILE" ROS_CONTAINER_NAME="$CONTAINER" \ + docker compose -f "$COMPOSE" down --volumes 2>/dev/null || true + +# Build and start +echo "--- Starting container ---" +ROS_DOCKERFILE="$DOCKERFILE" ROS_CONTAINER_NAME="$CONTAINER" \ + docker compose -f "$COMPOSE" up --build -d --wait + +# Run quick detect +echo "" +echo "--- Quick detect ---" +uv run python tests/integration/test_quick_detect.py + +# Run pytest +echo "" +echo "--- Pytest ---" +uv run pytest tests/integration/test_detect_version.py -v --ros-distro "$DISTRO" + +# Tear down +echo "" +echo "--- Tearing down ---" +ROS_DOCKERFILE="$DOCKERFILE" ROS_CONTAINER_NAME="$CONTAINER" \ + docker compose -f "$COMPOSE" down --volumes diff --git a/tests/integration/scripts/run-tests-all-distros.sh b/tests/integration/scripts/run-tests-all-distros.sh new file mode 100755 index 0000000..3f34524 --- /dev/null +++ b/tests/integration/scripts/run-tests-all-distros.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# Run integration tests against ALL supported distros. +# Usage: ./tests/integration/scripts/run-tests-all-distros.sh + +set -e +cd "$(git rev-parse --show-toplevel)" + +DISTROS=(melodic noetic humble jazzy) +PASSED=() +FAILED=() + +for distro in "${DISTROS[@]}"; do + echo "" + echo "========================================" + echo " Testing: $distro" + echo "========================================" + echo "" + if ./tests/integration/scripts/run-tests.sh "$distro"; then + PASSED+=("$distro") + else + FAILED+=("$distro") + fi +done + +echo "" +echo "========================================" +echo " RESULTS" +echo "========================================" +echo "Passed: ${PASSED[*]:-none}" +echo "Failed: ${FAILED[*]:-none}" +echo "" + +if [ ${#FAILED[@]} -gt 0 ]; then + exit 1 +fi diff --git a/tests/integration/scripts/run-tests.sh b/tests/integration/scripts/run-tests.sh new file mode 100755 index 0000000..65528ee --- /dev/null +++ b/tests/integration/scripts/run-tests.sh @@ -0,0 +1,86 @@ +#!/bin/bash +# Run integration tests against a specific ROS distro. +# Usage: ./tests/integration/scripts/run-tests.sh [module] +# Example: ./tests/integration/scripts/run-tests.sh noetic +# Example: ./tests/integration/scripts/run-tests.sh humble topics + +set -e +cd "$(git rev-parse --show-toplevel)" + +if [ -z "${1:-}" ]; then + MODULES=$(ls tests/integration/test_*.py 2>/dev/null \ + | sed 's|tests/integration/test_||;s|\.py||' \ + | grep -v quick_detect \ + | tr '\n' ', ' | sed 's/,$//') + echo "Usage: $0 [module]" + echo "Distros: melodic, noetic, humble, jazzy" + echo "Modules: $MODULES" + exit 1 +fi + +DISTRO="$1" +MODULE="${2:-}" +COMPOSE="tests/integration/docker-compose.yml" + +declare -A DOCKERFILES=( + [melodic]="Dockerfile.ros1-melodic" + [noetic]="Dockerfile.ros1-noetic" + [humble]="Dockerfile.ros2-humble" + [jazzy]="Dockerfile.ros2-jazzy" +) + +declare -A CONTAINERS=( + [melodic]="integration-ros-melodic" + [noetic]="integration-ros-noetic" + [humble]="integration-ros2-humble" + [jazzy]="integration-ros2-jazzy" +) + +DOCKERFILE="${DOCKERFILES[$DISTRO]}" +CONTAINER="${CONTAINERS[$DISTRO]}" + +if [ -z "$DOCKERFILE" ]; then + echo "Unknown distro: $DISTRO" + echo "Valid options: melodic, noetic, humble, jazzy" + exit 1 +fi + +echo "=== Testing $DISTRO ===" +echo "Dockerfile: $DOCKERFILE" +echo "Container: $CONTAINER" +echo "" + +# Tear down any previous container +ROS_DOCKERFILE="$DOCKERFILE" ROS_CONTAINER_NAME="$CONTAINER" \ + docker compose -f "$COMPOSE" down --volumes 2>/dev/null || true + +# Build and start +echo "--- Starting container ---" +ROS_DOCKERFILE="$DOCKERFILE" ROS_CONTAINER_NAME="$CONTAINER" \ + docker compose -f "$COMPOSE" up --build -d --wait + +# Run quick detect +echo "" +echo "--- Quick detect ---" +uv run python tests/integration/test_quick_detect.py + +# Run pytest +echo "" +echo "--- Pytest ---" +if [ -n "$MODULE" ]; then + TEST_PATH="tests/integration/test_${MODULE}.py" + if [ ! -f "$TEST_PATH" ]; then + echo "Unknown module: $MODULE" + echo "Available: $(ls tests/integration/test_*.py | sed 's|tests/integration/test_||;s|\.py||' | tr '\n' ' ')" + exit 1 + fi + uv run pytest "$TEST_PATH" -v --ros-distro "$DISTRO" --skip-compose +else + uv run pytest tests/integration/ -v --ros-distro "$DISTRO" --skip-compose +fi + +# Tear down +echo "" +echo "--- Tearing down ---" +ROS_DOCKERFILE="$DOCKERFILE" ROS_CONTAINER_NAME="$CONTAINER" \ + docker compose -f "$COMPOSE" down --volumes diff --git a/tests/integration/test_actions.py b/tests/integration/test_actions.py new file mode 100644 index 0000000..9fb021c --- /dev/null +++ b/tests/integration/test_actions.py @@ -0,0 +1,222 @@ +"""Integration tests for action tools. + +These tests call the actual MCP tool functions (get_actions, get_action_details, +get_action_status, send_action_goal, cancel_action_goal) against a live +rosbridge container. + +Action servers per distro: +- ROS 1 (melodic/noetic): /shape_server (from turtle_actionlib) +- ROS 2 (humble/jazzy): /turtle1/rotate_absolute (from turtlesim) + +get_action_details and get_action_status use ROS 2-only rosbridge features +(/rosapi/interfaces, send_action_goal op). See issue #320 for ROS 1 support +via the 5-topic actionlib pattern. +""" + +import asyncio + +import pytest + +from ros_mcp.utils.rosapi_types import RosVersion, get_ros_version + +pytestmark = [pytest.mark.integration] + + +_ACTIONS = { + RosVersion.ROS1: { + "name": "/turtle_shape", + "type": "turtle_actionlib/ShapeAction", + }, + RosVersion.ROS2: { + "name": "/turtle1/rotate_absolute", + "type": "turtlesim/action/RotateAbsolute", + }, +} + + +def _action(): + """Return the action dict (name, type) for the current distro.""" + return _ACTIONS[get_ros_version()] + + +class TestGetActions: + """Verify get_actions MCP tool returns the action list.""" + + def test_returns_actions(self, tools): + """get_actions should return actions and action_count.""" + result = tools["get_actions"]() + assert "actions" in result + assert "action_count" in result + assert result["action_count"] > 0 + assert result["action_count"] == len(result["actions"]) + + def test_includes_expected_action(self, tools): + """The expected action server should be present.""" + result = tools["get_actions"]() + actions = result["actions"] + expected = _action()["name"] + assert any(expected in a for a in actions), f"{expected} not in {actions}" + + +class TestGetActionDetails: + """Verify get_action_details MCP tool returns action structure. + + Detail inspection requires /rosapi/interfaces (ROS 2 only). + """ + + @pytest.mark.skipif( + "get_ros_version() != RosVersion.ROS2", + reason="Action detail inspection requires /rosapi/interfaces (ROS 2 only, see #320)", + ) + def test_action_details(self, tools): + """get_action_details should return goal/result/feedback structure.""" + action = _action()["name"] + result = tools["get_action_details"](action=action, action_type=_action()["type"]) + assert result["action"] == action + assert "goal" in result + assert "result" in result + assert "feedback" in result + assert result["goal"]["field_count"] > 0 + + def test_empty_action_returns_error(self, tools): + """get_action_details with empty string should return error.""" + result = tools["get_action_details"](action="") + assert "error" in result + + def test_missing_action_type_returns_error(self, tools): + """Without action_type, the error must also list the available types.""" + result = tools["get_action_details"](action="/nonexistent_action_xyz") + assert "error" in result + assert "action_type is required" in result["error"] + # The caller is told which types they could pass instead. + assert isinstance(result["available_action_types"], list) + + @pytest.mark.skipif( + "get_ros_version() != RosVersion.ROS2", + reason="action_type validation/crash is ROS 2 rosapi-specific (see #320)", + ) + def test_nonexistent_action_type_does_not_crash_rosapi(self, tools): + """A bogus action_type must be rejected WITHOUT crashing rosapi. + + get_action_details validates action_type against /rosapi/interfaces + before querying its details, because asking rosapi for the + goal/result/feedback of a non-existent type crashes the ROS 2 rosapi + node โ€” taking down every rosapi service for the rest of the session. + This test locks in that guard: it asserts the type is rejected AND that + rosapi is still answering afterwards. + """ + result = tools["get_action_details"]( + action="/nonexistent_action_xyz", + action_type="nonexistent_pkg/action/DoesNotExist", + ) + # An unknown type must be reported as an error, never silently accepted. + assert "error" in result + + # The crash check โ€” this is the assertion that catches the regression, + # independent of how the error is worded. Querying detail services for a + # non-existent type used to kill the ROS 2 rosapi node; a follow-up + # rosapi-backed call then fails with "service does not exist". If the + # guard is removed, this assertion fails here. + nodes_result = tools["get_nodes"]() + assert "nodes" in nodes_result, f"rosapi crashed after bogus action_type: {nodes_result}" + + # The rejection should come from the interfaces validation, not from + # reaching (and crashing on) the detail services. + assert "not found" in result["error"].lower() + assert isinstance(result["available_action_types"], list) + + +class TestGetActionStatus: + """Verify get_action_status MCP tool returns status info. + + Status subscription works on ROS 2. On ROS 1, the status topic + format differs (see #320). + """ + + @pytest.mark.skipif( + "get_ros_version() != RosVersion.ROS2", + reason="Action status subscription uses ROS 2 topic format (see #320)", + ) + def test_action_status(self, tools): + """get_action_status should echo the action and report goal status. + + With no goal in flight the action is reported as idle (goal_count 0), + not an error; goal_count must always match the active_goals list. + """ + action_name = _action()["name"] + result = tools["get_action_status"](action_name=action_name) + assert result.get("action_name") == action_name + assert "error" not in result, f"unexpected error: {result}" + assert result["goal_count"] == len(result["active_goals"]) + + def test_empty_action_returns_error(self, tools): + """get_action_status with empty string should return error.""" + result = tools["get_action_status"](action_name="") + assert "error" in result + + +class TestSendActionGoal: + """Verify send_action_goal MCP tool. + + Sending a goal uses the ROS 2 rosbridge ``send_action_goal`` op; on ROS 1 + actions use a different transport (see #320). The async tool is driven with + ``asyncio.run`` since the suite has no asyncio plugin. + """ + + def test_empty_action_returns_error(self, tools): + result = asyncio.run( + tools["send_action_goal"](action_name="", action_type="x", goal={"a": 1}) + ) + assert "error" in result + + def test_empty_type_returns_error(self, tools): + result = asyncio.run( + tools["send_action_goal"](action_name="/a", action_type="", goal={"a": 1}) + ) + assert "error" in result + + def test_empty_goal_returns_error(self, tools): + result = asyncio.run(tools["send_action_goal"](action_name="/a", action_type="x", goal={})) + assert "error" in result + + @pytest.mark.skipif( + "get_ros_version() != RosVersion.ROS2", + reason="send_action_goal uses the ROS 2 rosbridge action op (see #320)", + ) + def test_send_goal_completes(self, tools): + """A goal to rotate_absolute should run to completion and return a result.""" + action = _action() + result = asyncio.run( + tools["send_action_goal"]( + action_name=action["name"], + action_type=action["type"], + goal={"theta": 1.0}, + timeout=10.0, + ) + ) + assert result["success"] is True, f"goal did not complete: {result}" + assert result["goal_id"] + assert "result" in result + + +class TestCancelActionGoal: + """Verify cancel_action_goal MCP tool.""" + + def test_empty_action_returns_error(self, tools): + result = tools["cancel_action_goal"](action_name="", goal_id="g") + assert "error" in result + + def test_empty_goal_id_returns_error(self, tools): + result = tools["cancel_action_goal"](action_name="/a", goal_id="") + assert "error" in result + + @pytest.mark.skipif( + "get_ros_version() != RosVersion.ROS2", + reason="cancel_action_goal uses the ROS 2 rosbridge action op (see #320)", + ) + def test_cancel_request_sent(self, tools): + """Cancelling reports success once the request is sent (not server-acked).""" + action_name = _action()["name"] + result = tools["cancel_action_goal"](action_name=action_name, goal_id="nonexistent_goal") + assert result["success"] is True + assert result["action"] == action_name diff --git a/tests/integration/test_connection.py b/tests/integration/test_connection.py new file mode 100644 index 0000000..17c5330 --- /dev/null +++ b/tests/integration/test_connection.py @@ -0,0 +1,78 @@ +"""Integration tests for connection tools (step 1). + +These tests call the actual MCP tool functions (connect_to_robot, +ping_robots) against a live rosbridge container. +""" + +import pytest + +pytestmark = [pytest.mark.integration] + + +class TestConnectToRobot: + """Verify connect_to_robot tool sets IP/port and tests connectivity.""" + + def test_connect_localhost(self, tools): + """connect_to_robot with default args should succeed against rosbridge.""" + result = tools["connect_to_robot"]() + assert "message" in result + assert "connectivity_test" in result + assert result["connectivity_test"]["port_check"]["open"] is True + + def test_connect_explicit_ip_port(self, tools): + """connect_to_robot with explicit IP/port should succeed.""" + result = tools["connect_to_robot"](ip="127.0.0.1", port=9090) + assert "connectivity_test" in result + assert result["connectivity_test"]["port_check"]["open"] is True + + def test_connect_wrong_port(self, tools): + """connect_to_robot to a closed port should report port closed.""" + result = tools["connect_to_robot"](ip="127.0.0.1", port=19999, port_timeout=1.0) + assert result["connectivity_test"]["port_check"]["open"] is False + # Restore ws_manager to the correct port for subsequent tests + tools["connect_to_robot"](ip="127.0.0.1", port=9090) + + +class TestPingRobots: + """Verify ping_robots tool checks connectivity for multiple targets.""" + + def test_ping_rosbridge(self, tools): + """ping_robots with rosbridge target should report port open.""" + result = tools["ping_robots"]( + targets=[{"ip": "127.0.0.1", "port": 9090}], + ) + assert "results" in result + assert len(result["results"]) == 1 + assert result["results"][0]["port_check"]["open"] is True + + def test_ping_closed_port(self, tools): + """ping_robots with a closed port should report port closed.""" + result = tools["ping_robots"]( + targets=[{"ip": "127.0.0.1", "port": 19999}], + port_timeout=1.0, + ) + assert result["results"][0]["port_check"]["open"] is False + + def test_ping_multiple_targets(self, tools): + """ping_robots with mixed targets should return results for each.""" + result = tools["ping_robots"]( + targets=[ + {"ip": "127.0.0.1", "port": 9090}, + {"ip": "127.0.0.1", "port": 19999}, + ], + port_timeout=1.0, + ) + assert len(result["results"]) == 2 + assert result["results"][0]["port_check"]["open"] is True + assert result["results"][1]["port_check"]["open"] is False + + def test_ping_default_target(self, tools): + """ping_robots with no args should use default localhost:9090.""" + result = tools["ping_robots"]() + assert "results" in result + assert len(result["results"]) == 1 + + def test_ping_empty_list(self, tools): + """ping_robots with empty list should return error.""" + result = tools["ping_robots"](targets=[]) + assert "error" in result diff --git a/tests/integration/test_detect_version.py b/tests/integration/test_detect_version.py new file mode 100644 index 0000000..f18f1ee --- /dev/null +++ b/tests/integration/test_detect_version.py @@ -0,0 +1,107 @@ +"""Integration tests for detection tools. + +These tests verify the rosapi_types module (service prefix, type format, +version detection) and the detect_ros_version MCP tool. +""" + +import pytest + +from ros_mcp.utils.rosapi_types import ( + RosVersion, + get_distro, + get_ros_version, + rosapi_service, + rosapi_type, +) + +pytestmark = [pytest.mark.integration] + +_DISTRO_TO_VERSION = { + "melodic": RosVersion.ROS1, + "noetic": RosVersion.ROS1, + "humble": RosVersion.ROS2, + "jazzy": RosVersion.ROS2, +} + +_DISTRO_TO_PREFIX = { + "melodic": "/rosapi", + "noetic": "/rosapi", + "humble": "/rosapi", + "jazzy": "/rosapi", +} + + +class TestDetectRosVersion: + """Verify rosapi_types module: version detection, service prefixes, type format.""" + + def test_version_is_detected(self, ws): + """Version should always be determined (never falls through).""" + version = get_ros_version() + assert version in (RosVersion.ROS1, RosVersion.ROS2) + + def test_version_matches_distro(self, ws, ros_distro): + """get_ros_version() should return the correct enum for the launched distro.""" + expected = _DISTRO_TO_VERSION[ros_distro] + assert get_ros_version() == expected + + def test_distro_matches(self, ws, ros_distro): + """Detected distro should match the distro we launched.""" + detected = get_distro() + assert detected == ros_distro, f"Expected distro '{ros_distro}', got '{detected}'" + + def test_ros2_has_distro(self, ws): + """On ROS 2, distro should always be detected.""" + if get_ros_version() == RosVersion.ROS2: + assert get_distro() != "", "ROS 2 should always report a distro" + + def test_service_prefix(self, ws, ros_distro): + """Service prefix should match the known prefix for this distro.""" + expected_prefix = _DISTRO_TO_PREFIX[ros_distro] + assert rosapi_service("nodes") == f"{expected_prefix}/nodes" + assert rosapi_service("topics") == f"{expected_prefix}/topics" + + def test_type_format(self, ws, ros_distro): + """Type format should match the detected ROS version.""" + expected = _DISTRO_TO_VERSION[ros_distro] + if expected == RosVersion.ROS2: + assert rosapi_type("Services") == "rosapi_msgs/srv/Services" + assert rosapi_type("Topics") == "rosapi_msgs/srv/Topics" + else: + assert rosapi_type("Services") == "rosapi/Services" + assert rosapi_type("Topics") == "rosapi/Topics" + + def test_resolved_service_works(self, tools): + """Resolved service paths should work end-to-end via get_nodes tool.""" + result = tools["get_nodes"]() + assert "nodes" in result, f"get_nodes failed: {result}" + assert len(result["nodes"]) > 0, "Should find at least one node (turtlesim)" + + +class TestDetectRosVersionTool: + """Test the detect_ros_version MCP tool function directly.""" + + def test_tool_returns_version_and_distro(self, tools, ros_distro): + """detect_ros_version tool should return version and distro matching the container.""" + result = tools["detect_ros_version"]() + assert "error" not in result, f"Tool returned error: {result}" + assert "version" in result + assert "distro" in result + assert result["distro"] == ros_distro + + def test_tool_version_is_consistent_string(self, tools): + """version should always be a string ('1' or '2').""" + result = tools["detect_ros_version"]() + assert isinstance(result["version"], str) + assert result["version"] in ("1", "2") + + def test_tool_ros2_version(self, tools): + """On ROS 2, version should be '2'.""" + result = tools["detect_ros_version"]() + if get_ros_version() == RosVersion.ROS2: + assert result["version"] == "2" + + def test_tool_ros1_version(self, tools): + """On ROS 1, version should be '1'.""" + result = tools["detect_ros_version"]() + if get_ros_version() == RosVersion.ROS1: + assert result["version"] == "1" diff --git a/tests/integration/test_nodes.py b/tests/integration/test_nodes.py new file mode 100644 index 0000000..e83f37f --- /dev/null +++ b/tests/integration/test_nodes.py @@ -0,0 +1,97 @@ +"""Integration tests for node tools (step 2). + +These tests call the actual MCP tool functions (get_nodes, get_node_details) +against a live rosbridge container. + +Note: calling get_node_details for nonexistent nodes crashes rosapi_node +on ROS 2 (#273). Tests only query nodes confirmed to exist. +""" + +import pytest + +pytestmark = [pytest.mark.integration] + + +class TestGetNodes: + """Verify get_nodes MCP tool returns the running node list.""" + + def test_returns_nodes(self, tools): + """get_nodes should return nodes and node_count.""" + result = tools["get_nodes"]() + assert "nodes" in result + assert "node_count" in result + assert result["node_count"] > 0 + assert result["node_count"] == len(result["nodes"]) + + def test_includes_turtlesim(self, tools): + """turtlesim node should be present (launched by Docker container).""" + result = tools["get_nodes"]() + nodes = result["nodes"] + assert any("/turtlesim" in n for n in nodes), f"turtlesim not in {nodes}" + + def test_includes_rosbridge(self, tools): + """rosbridge node should be present.""" + result = tools["get_nodes"]() + nodes = result["nodes"] + assert any("rosbridge" in n for n in nodes), f"rosbridge not in {nodes}" + + def test_node_count_at_least_three(self, tools): + """Should have at least 3 nodes: turtlesim, rosbridge, rosapi.""" + result = tools["get_nodes"]() + assert result["node_count"] >= 3, ( + f"Expected >= 3, got {result['node_count']}: {result['nodes']}" + ) + + def test_all_nodes_are_ros_names(self, tools): + """Every node name should be a string starting with /.""" + result = tools["get_nodes"]() + for node in result["nodes"]: + assert isinstance(node, str), f"Expected string, got {type(node)}: {node}" + assert node.startswith("/"), f"Node name should start with /: {node}" + + +class TestGetNodeDetails: + """Verify get_node_details MCP tool returns publishers/subscribers/services. + + Only queries nodes confirmed to exist โ€” calling get_node_details for + nonexistent nodes crashes rosapi_node on ROS 2 (#273). + """ + + def test_turtlesim_details(self, tools): + """get_node_details for /turtlesim should return publishers and subscribers.""" + result = tools["get_node_details"](node="/turtlesim") + assert result["node"] == "/turtlesim" + assert result["publisher_count"] > 0, "turtlesim should have publishers" + assert result["subscriber_count"] > 0, "turtlesim should have subscribers" + assert any("pose" in p for p in result["publishers"]), f"No pose in {result['publishers']}" + assert any("cmd_vel" in s for s in result["subscribers"]), ( + f"No cmd_vel in {result['subscribers']}" + ) + + def test_rosbridge_has_services(self, tools): + """get_node_details for rosbridge should return services.""" + # Find rosbridge node name dynamically (varies by distro) + nodes_result = tools["get_nodes"]() + rosbridge = next((n for n in nodes_result["nodes"] if "rosbridge" in n), None) + assert rosbridge is not None + + result = tools["get_node_details"](node=rosbridge) + assert result["node"] == rosbridge + assert result["service_count"] > 0, "rosbridge should have services" + + def test_detail_counts_match_lists(self, tools): + """publisher_count/subscriber_count/service_count should match list lengths.""" + result = tools["get_node_details"](node="/turtlesim") + assert result["publisher_count"] == len(result["publishers"]) + assert result["subscriber_count"] == len(result["subscribers"]) + assert result["service_count"] == len(result["services"]) + + def test_empty_node_name_returns_error(self, tools): + """get_node_details with empty string should return error dict.""" + result = tools["get_node_details"](node="") + assert "error" in result + + def test_whitespace_node_name_returns_error(self, tools): + """get_node_details with whitespace should return error dict.""" + result = tools["get_node_details"](node=" ") + assert "error" in result diff --git a/tests/integration/test_parameters.py b/tests/integration/test_parameters.py new file mode 100644 index 0000000..c66a3c6 --- /dev/null +++ b/tests/integration/test_parameters.py @@ -0,0 +1,185 @@ +"""Integration tests for parameter tools. + +These tests call the actual MCP tool functions (get_parameter, set_parameter, +has_parameter, delete_parameter, get_parameters, get_parameter_details) +against a live rosbridge container. + +Parameter semantics differ across ROS versions: +- ROS 1 (melodic/noetic): global parameter server, slash-separated names + (e.g. /turtlesim/background_r). The test seeds a known parameter via + set_parameter before reading. +- ROS 2 (humble/jazzy): per-node parameters keyed "node:name" through + rosbridge (e.g. /turtlesim:background_r). turtlesim populates background_r/g/b + on launch. + +get_parameters relies on rcl_interfaces/srv/ListParameters and is ROS 2-only; +on ROS 1 the tool returns a graceful "service does not exist" error, which the +test asserts directly. +""" + +import time + +import pytest + +from ros_mcp.utils.rosapi_types import RosVersion, get_ros_version + +pytestmark = [pytest.mark.integration] + + +_PARAMS = { + RosVersion.ROS1: { + "seed_name": "/ros_mcp_test_param", + "seed_value": "42", + # On ROS 1, the only param we know exists post-fixture is the one we seed. + "existing_name": "/ros_mcp_test_param", + }, + RosVersion.ROS2: { + "seed_name": "/turtlesim:background_r", + "seed_value": "100", + # On ROS 2, turtlesim populates background_r on launch. + "existing_name": "/turtlesim:background_r", + }, +} + + +def _params_for_current_version() -> dict: + version = get_ros_version() + return _PARAMS[version] + + +@pytest.fixture +def params(): + return _params_for_current_version() + + +@pytest.fixture +def ros1_seeded_param(tools, params): + """On ROS 1, set a known parameter before the test and clean it up after.""" + if get_ros_version() != RosVersion.ROS1: + yield params + return + tools["set_parameter"](name=params["seed_name"], value=params["seed_value"]) + yield params + tools["delete_parameter"](name=params["seed_name"]) + + +class TestGetParameter: + def test_returns_value_for_known_parameter(self, tools, ros1_seeded_param): + result = tools["get_parameter"](name=ros1_seeded_param["existing_name"]) + assert "value" in result + assert result.get("successful") is True + assert str(result["value"]).strip() != "" + + def test_empty_name_returns_error(self, tools): + result = tools["get_parameter"](name="") + assert "error" in result + + def test_whitespace_name_returns_error(self, tools): + result = tools["get_parameter"](name=" ") + assert "error" in result + + +class TestSetParameter: + def test_set_succeeds(self, tools, params): + version = get_ros_version() + if version == RosVersion.ROS1: + unique_name = f"/ros_mcp_test_set_{int(time.time_ns())}" + result = tools["set_parameter"](name=unique_name, value="7") + assert result.get("successful") is True + tools["delete_parameter"](name=unique_name) + return + # ROS 2: mutating turtlesim's params, so capture and restore. + target = params["seed_name"] + original = tools["get_parameter"](name=target).get("value") + try: + result = tools["set_parameter"](name=target, value="7") + assert result.get("successful") is True + finally: + if original is not None: + tools["set_parameter"](name=target, value=str(original)) + + def test_empty_name_returns_error(self, tools): + result = tools["set_parameter"](name="", value="1") + assert "error" in result + + +class TestHasParameter: + def test_existing_parameter(self, tools, ros1_seeded_param): + result = tools["has_parameter"](name=ros1_seeded_param["existing_name"]) + assert result["exists"] is True + + def test_nonexistent_parameter(self, tools): + result = tools["has_parameter"](name="/ros_mcp_does_not_exist_xyz") + assert result["exists"] is False + + def test_empty_name_returns_error(self, tools): + result = tools["has_parameter"](name="") + assert "error" in result + + +class TestDeleteParameter: + def test_delete_round_trip(self, tools): + if get_ros_version() == RosVersion.ROS2: + pytest.skip( + "ROS 2 delete-success is not exercisable against this setup: turtlesim " + "silently drops undeclared params (set 'succeeds' but nothing persists " + "to delete), and deleting a declared param crashes the rosapi node. The " + "testable ROS 2 delete paths (nonexistent -> unsuccessful, empty name -> " + "error) are covered by the other tests in this class." + ) + name = f"/ros_mcp_test_delete_{int(time.time_ns())}" + tools["set_parameter"](name=name, value="1") + assert tools["has_parameter"](name=name)["exists"] is True + result = tools["delete_parameter"](name=name) + assert result.get("successful") is True + assert tools["has_parameter"](name=name)["exists"] is False + + def test_nonexistent_returns_unsuccessful(self, tools): + result = tools["delete_parameter"](name="/ros_mcp_does_not_exist_xyz") + assert result.get("successful") is False + + def test_empty_name_returns_error(self, tools): + result = tools["delete_parameter"](name="") + assert "error" in result + + +class TestGetParameters: + def test_returns_list_for_known_node(self, tools): + result = tools["get_parameters"](node_name="turtlesim") + if get_ros_version() == RosVersion.ROS1: + # get_parameters relies on rcl_interfaces/srv/ListParameters which only + # exists on ROS 2. The tool documents "Works only with ROS 2" and returns + # a graceful error pointing at the missing per-node service. + assert "error" in result + assert "list_parameters" in result["error"] + return + assert "parameters" in result + assert "parameter_count" in result + assert isinstance(result["parameters"], list) + + def test_empty_node_returns_error(self, tools): + result = tools["get_parameters"](node_name="") + assert "error" in result + + +class TestGetParameterDetails: + def test_returns_details_for_known_parameter(self, tools, ros1_seeded_param): + # On ROS 1, the describe_parameters call fails internally but the tool + # falls back to type inference from the value, so the test passes on both + # versions. On ROS 2, describe_parameters supplies the type. + result = tools["get_parameter_details"](name=ros1_seeded_param["existing_name"]) + assert result["exists"] is True + assert "value" in result + assert "type" in result + + def test_nonexistent_returns_error_response(self, tools): + if get_ros_version() == RosVersion.ROS1: + name = "/ros_mcp_definitely_does_not_exist" + else: + name = "/turtlesim:does_not_exist" + result = tools["get_parameter_details"](name=name) + assert result["exists"] is False + + def test_empty_name_returns_error(self, tools): + result = tools["get_parameter_details"](name="") + assert "error" in result diff --git a/tests/integration/test_quick_detect.py b/tests/integration/test_quick_detect.py new file mode 100644 index 0000000..d52da3f --- /dev/null +++ b/tests/integration/test_quick_detect.py @@ -0,0 +1,76 @@ +"""Quick standalone test: detect ROS version against a running rosbridge. + +Usage (assumes rosbridge may or may not be running on port 9090): + + uv run python tests/integration/test_quick_detect.py + +Scenarios: + - No rosbridge running โ†’ DetectionError (expected) + - ROS 1 Noetic running โ†’ ROS1, prefix=/rosapi + - ROS 2 Humble running โ†’ ROS2, prefix=/rosapi + - ROS 2 Jazzy running โ†’ ROS2, prefix=/rosapi_node +""" + +import json +import sys + +sys.path.insert(0, ".") + +from ros_mcp.utils.rosapi_types import ( # noqa: E402 + DetectionError, + _reset_resolver, + detect_rosapi_types, + get_distro, + get_ros_version, + rosapi_service, + rosapi_type, +) +from ros_mcp.utils.websocket import WebSocketManager # noqa: E402 + + +def main() -> None: + ws = WebSocketManager("127.0.0.1", 9090, default_timeout=3.0) + _reset_resolver() + + try: + detect_rosapi_types(ws) + except DetectionError as e: + print(f"DETECTION FAILED: {e}") + print("This is expected if no rosbridge is running.") + sys.exit(1) + + version = get_ros_version() + distro = get_distro() + + print(f"version = {version}") + print(f"distro = {distro!r}") + print(f"prefix = {rosapi_service('nodes')}") + print(f"type = {rosapi_type('Topics')}") + print() + + # Verify a real call works + ws.connect() + msg = { + "op": "call_service", + "service": rosapi_service("nodes"), + "type": rosapi_type("Nodes"), + "args": {}, + } + ws.ws.send(json.dumps(msg)) + resp = json.loads(ws.ws.recv()) + ws.close() + + if resp.get("result") is False: + print(f"SERVICE CALL FAILED: {resp}") + sys.exit(1) + + nodes = resp.get("values", {}).get("nodes", []) + print(f"nodes = {nodes}") + print(f"result = OK ({len(nodes)} nodes found)") + + +# Guard execution so pytest can import this module during collection without +# attempting a live rosbridge connection (which aborts the whole test session). +# Run manually with: uv run python tests/integration/test_quick_detect.py +if __name__ == "__main__": + main() diff --git a/tests/integration/test_ros_metadata.py b/tests/integration/test_ros_metadata.py new file mode 100644 index 0000000..d7b6ffd --- /dev/null +++ b/tests/integration/test_ros_metadata.py @@ -0,0 +1,244 @@ +"""Integration tests for the ros-metadata MCP resources (step 7). + +These exercise the five ``ros-mcp://ros-metadata/*`` resources registered by +``register_ros_metadata_resources`` against a live rosbridge container. Each +resource returns a JSON string; tests parse it and assert the payload shape plus +known turtlesim entities. + +Resource URIs: +- ``ros-mcp://ros-metadata/all`` โ†’ topics/services/nodes/params/version +- ``ros-mcp://ros-metadata/nodes/all`` โ†’ per-node publishers/subscribers/services +- ``ros-mcp://ros-metadata/services/all`` โ†’ per-service type/providers +- ``ros-mcp://ros-metadata/topics/all`` โ†’ per-topic type/publishers/subscribers +- ``ros-mcp://ros-metadata/actions/all`` โ†’ per-action type/status + +Action inspection uses the rosapi ``action_servers`` service, which is a ROS 2 +rosbridge feature. On ROS 1 the resource returns a structured +"not supported" payload instead of an action list (see #320), so content +assertions for actions are gated to ROS 2. +""" + +import json + +import pytest +from fastmcp import FastMCP + +from ros_mcp.resources import register_all_resources +from ros_mcp.utils.rosapi_types import RosVersion, get_ros_version +from ros_mcp.utils.websocket import WebSocketManager + +pytestmark = [pytest.mark.integration] + +ALL = "ros-mcp://ros-metadata/all" +NODES = "ros-mcp://ros-metadata/nodes/all" +SERVICES = "ros-mcp://ros-metadata/services/all" +TOPICS = "ros-mcp://ros-metadata/topics/all" +ACTIONS = "ros-mcp://ros-metadata/actions/all" + + +def _read(resources, uri): + """Read a resource by URI and parse its JSON payload into a dict.""" + payload = resources[uri]() + assert isinstance(payload, str), f"resource {uri} should return a JSON string" + return json.loads(payload) + + +def _assert_counts_match(name, detail, kinds): + """Assert each ``_count`` equals the length of its ``s`` list.""" + for kind in kinds: + assert detail[f"{kind}_count"] == len(detail[f"{kind}s"]), (name, kind, detail) + + +class TestAllMetadata: + """Verify ros-mcp://ros-metadata/all aggregates the system snapshot.""" + + def test_returns_core_sections(self, resources): + """The snapshot exposes topics/services/nodes/parameters as lists.""" + data = _read(resources, ALL) + for key in ("topics", "services", "nodes", "parameters"): + assert isinstance(data[key], list), f"{key} should be a list" + assert isinstance(data["summary"], dict) + + def test_reports_detected_version(self, resources): + """ros_version reflects the resolver detected at connect time.""" + data = _read(resources, ALL) + expected = "2" if get_ros_version() == RosVersion.ROS2 else "1" + assert data["ros_version"]["version"] == expected + assert isinstance(data["ros_version"]["distro"], str) + + def test_summary_counts_match_lists(self, resources): + """summary totals match the lengths of their corresponding lists.""" + data = _read(resources, ALL) + summary = data["summary"] + assert summary["total_topics"] == len(data["topics"]) + assert summary["total_services"] == len(data["services"]) + assert summary["total_nodes"] == len(data["nodes"]) + assert summary["total_parameters"] == len(data["parameters"]) + + def test_includes_turtlesim_node(self, resources): + """The turtlesim node launched by the container is present.""" + data = _read(resources, ALL) + assert any("/turtlesim" in n for n in data["nodes"]), f"turtlesim not in {data['nodes']}" + + def test_topics_carry_names_and_types(self, resources): + """Each topic entry is a {name, type} dict with a leading-slash name.""" + data = _read(resources, ALL) + assert data["topics"], "expected at least one topic" + for entry in data["topics"]: + assert entry["name"].startswith("/"), f"topic name should start with /: {entry}" + assert entry["type"], f"topic type should be non-empty: {entry}" + + def test_no_errors_on_healthy_container(self, resources): + """A healthy turtlesim snapshot reports no collection errors.""" + data = _read(resources, ALL) + assert data["errors"] == [], f"unexpected errors: {data['errors']}" + assert data["summary"]["has_errors"] is False + + +class TestNodesDetails: + """Verify ros-mcp://ros-metadata/nodes/all returns per-node connections.""" + + def test_shape(self, resources): + """Payload has total_nodes, a nodes map, and a node_errors list.""" + data = _read(resources, NODES) + assert data["total_nodes"] > 0 + assert isinstance(data["nodes"], dict) + assert data["total_nodes"] == len(data["nodes"]) + assert isinstance(data["node_errors"], list) + + def test_turtlesim_has_pubs_and_subs(self, resources): + """turtlesim publishes (pose) and subscribes (cmd_vel).""" + data = _read(resources, NODES) + turtlesim = next((n for n in data["nodes"] if "/turtlesim" in n), None) + assert turtlesim is not None, f"turtlesim not in {list(data['nodes'])}" + detail = data["nodes"][turtlesim] + assert detail["publisher_count"] > 0, "turtlesim should publish" + assert detail["subscriber_count"] > 0, "turtlesim should subscribe" + + def test_counts_match_lists(self, resources): + """Each node's *_count fields match the lengths of their lists.""" + data = _read(resources, NODES) + for name, detail in data["nodes"].items(): + _assert_counts_match(name, detail, ("publisher", "subscriber", "service")) + + +class TestServicesDetails: + """Verify ros-mcp://ros-metadata/services/all returns per-service info.""" + + def test_shape(self, resources): + """Payload has total_services, a services map, and a service_errors list.""" + data = _read(resources, SERVICES) + assert data["total_services"] > 0 + assert isinstance(data["services"], dict) + assert data["total_services"] == len(data["services"]) + assert isinstance(data["service_errors"], list) + + def test_provider_count_matches_list(self, resources): + """Each service's provider_count matches its providers list length.""" + data = _read(resources, SERVICES) + for name, detail in data["services"].items(): + assert detail["provider_count"] == len(detail["providers"]), name + + def test_includes_rosapi_service(self, resources): + """The rosapi services that back this resource are themselves listed.""" + data = _read(resources, SERVICES) + assert any("rosapi" in s for s in data["services"]), ( + f"no rosapi service in {list(data['services'])[:10]}..." + ) + + +class TestTopicsDetails: + """Verify ros-mcp://ros-metadata/topics/all returns per-topic connections.""" + + def test_shape(self, resources): + """Payload has total_topics, a topics map, and a topic_errors list.""" + data = _read(resources, TOPICS) + assert data["total_topics"] > 0 + assert isinstance(data["topics"], dict) + assert data["total_topics"] == len(data["topics"]) + assert isinstance(data["topic_errors"], list) + + def test_turtle_pose_published(self, resources): + """/turtle1/pose exists, is typed, and has turtlesim as a publisher.""" + data = _read(resources, TOPICS) + assert "/turtle1/pose" in data["topics"], f"pose not in {list(data['topics'])}" + pose = data["topics"]["/turtle1/pose"] + assert pose["type"], "pose topic should carry a type" + assert pose["publisher_count"] > 0, "turtlesim should publish /turtle1/pose" + + def test_counts_match_lists(self, resources): + """Each topic's *_count fields match the lengths of their lists.""" + data = _read(resources, TOPICS) + for name, detail in data["topics"].items(): + _assert_counts_match(name, detail, ("publisher", "subscriber")) + + +class TestActionsDetails: + """Verify ros-mcp://ros-metadata/actions/all. + + Action inspection requires the rosapi ``action_servers`` service (ROS 2). + On ROS 1 the resource returns a structured "not supported" payload, so the + content assertion is gated to ROS 2. + """ + + def test_payload_is_structured(self, resources): + """On any distro the payload is a dict that parses cleanly. + + Either an action listing (total_actions/actions/action_errors) or, when + rosapi lacks action_servers, an error payload carrying a compatibility + explanation โ€” never a crash. + """ + data = _read(resources, ACTIONS) + assert isinstance(data, dict) + if "error" in data: + assert "compatibility" in data, f"error payload missing compatibility: {data}" + else: + assert isinstance(data["total_actions"], int) + assert isinstance(data["actions"], dict) + assert isinstance(data["action_errors"], list) + + @pytest.mark.skipif( + "get_ros_version() != RosVersion.ROS2", + reason="rosapi action_servers inspection is a ROS 2 feature (see #320)", + ) + def test_rotate_absolute_listed(self, resources): + """turtlesim's /turtle1/rotate_absolute action is discovered on ROS 2.""" + data = _read(resources, ACTIONS) + assert "error" not in data, f"unexpected error payload: {data}" + assert "/turtle1/rotate_absolute" in data["actions"], ( + f"rotate_absolute not in {list(data['actions'])}" + ) + assert "status" in data["actions"]["/turtle1/rotate_absolute"] + + +@pytest.fixture +def offline_resources(): + """Resources bound to an unreachable rosbridge (an unused local port). + + Exercises the graceful-degradation paths: with no rosbridge to talk to, + every resource must still return a structured JSON payload rather than + raise. Does not use the shared ``ws`` fixture, so it needs no container. + """ + ws = WebSocketManager("127.0.0.1", 1, default_timeout=1.0) + mcp = FastMCP("offline-ros-mcp") + register_all_resources(mcp, ws) + return {uri: res.fn for uri, res in mcp._resource_manager._resources.items()} + + +class TestOfflineDegradation: + """Every resource degrades gracefully when rosbridge is unreachable.""" + + @pytest.mark.parametrize("uri", [NODES, SERVICES, TOPICS, ACTIONS]) + def test_detail_resource_reports_structured_error(self, offline_resources, uri): + """Detail resources return a top-level error payload, never a crash.""" + data = json.loads(offline_resources[uri]()) + assert isinstance(data, dict) + assert "error" in data, f"{uri} should report a structured error offline: {data}" + + def test_all_metadata_returns_empty_snapshot(self, offline_resources): + """The aggregate snapshot still parses, with empty sections.""" + data = json.loads(offline_resources[ALL]()) + assert isinstance(data["summary"], dict) + assert data["topics"] == [] + assert data["services"] == [] + assert data["nodes"] == [] diff --git a/tests/integration/test_services.py b/tests/integration/test_services.py new file mode 100644 index 0000000..3e8e857 --- /dev/null +++ b/tests/integration/test_services.py @@ -0,0 +1,120 @@ +"""Integration tests for service tools. + +These tests call the actual MCP tool functions (get_services, get_service_type, +get_service_details, call_service) against a live rosbridge container. +""" + +import time + +import pytest + +pytestmark = [pytest.mark.integration] + + +class TestGetServices: + """Verify get_services MCP tool returns the service list.""" + + def test_returns_services(self, tools): + """get_services should return services and service_count.""" + result = tools["get_services"]() + assert "services" in result + assert "service_count" in result + assert result["service_count"] > 0 + assert result["service_count"] == len(result["services"]) + + def test_includes_rosapi_services(self, tools): + """rosapi services should be present (rosapi node is running).""" + result = tools["get_services"]() + services = result["services"] + assert any("nodes" in s for s in services), f"nodes service not in {services}" + + def test_includes_turtlesim_services(self, tools): + """turtlesim services like /clear or /spawn should be present.""" + result = tools["get_services"]() + services = result["services"] + assert any("spawn" in s for s in services), f"spawn not in {services}" + + +class TestGetServiceType: + """Verify get_service_type MCP tool returns the service type.""" + + def test_known_service_type(self, tools): + """get_service_type for a turtlesim service should return a type string.""" + result = tools["get_service_type"](service="/kill") + assert "type" in result + assert len(result["type"]) > 0 + + def test_empty_service_returns_error(self, tools): + """get_service_type with empty string should return error.""" + result = tools["get_service_type"](service="") + assert "error" in result + + def test_whitespace_service_returns_error(self, tools): + """get_service_type with whitespace should return error.""" + result = tools["get_service_type"](service=" ") + assert "error" in result + + +class TestGetServiceDetails: + """Verify get_service_details MCP tool returns full service definition.""" + + def test_spawn_details(self, tools): + """get_service_details for /spawn should return request/response fields.""" + result = tools["get_service_details"](service="/spawn") + assert result["service"] == "/spawn" + assert len(result["type"]) > 0 + assert "request" in result + assert "response" in result + + def test_empty_service_returns_error(self, tools): + """get_service_details with empty string should return error.""" + result = tools["get_service_details"](service="") + assert "error" in result + + def test_whitespace_service_returns_error(self, tools): + """get_service_details with whitespace should return error.""" + result = tools["get_service_details"](service=" ") + assert "error" in result + + +class TestCallService: + """Verify call_service MCP tool can call a live service.""" + + def test_call_clear(self, tools): + """call_service for /clear should succeed (clears drawing traces).""" + type_result = tools["get_service_type"](service="/clear") + result = tools["call_service"]( + service_name="/clear", + service_type=type_result["type"], + request={}, + ) + assert result["success"] is True + + def test_call_spawn_turtle(self, tools): + """call_service for /spawn should create a new turtle (cleaned up after).""" + turtle_name = f"test_turtle_{int(time.time_ns())}" + spawn_type = tools["get_service_type"](service="/spawn")["type"] + try: + result = tools["call_service"]( + service_name="/spawn", + service_type=spawn_type, + request={"x": 3.0, "y": 3.0, "theta": 0.0, "name": turtle_name}, + ) + assert result["success"] is True + finally: + # Remove the spawned turtle so state doesn't leak across tests. + kill_type = tools["get_service_type"](service="/kill")["type"] + tools["call_service"]( + service_name="/kill", + service_type=kill_type, + request={"name": turtle_name}, + ) + + def test_call_nonexistent_service(self, tools): + """call_service for a nonexistent service should return an error.""" + result = tools["call_service"]( + service_name="/this_service_does_not_exist", + service_type="std_srvs/srv/Empty", + request={}, + ) + assert result["success"] is False diff --git a/tests/integration/test_topics.py b/tests/integration/test_topics.py new file mode 100644 index 0000000..56f824a --- /dev/null +++ b/tests/integration/test_topics.py @@ -0,0 +1,245 @@ +"""Integration tests for topic tools. + +These tests call the actual MCP tool functions (get_topics, get_topic_type, +get_topic_details, get_message_details, subscribe_once, subscribe_for_duration, +publish_once, publish_for_durations) against a live rosbridge container. +""" + +import json +import time + +import pytest + +pytestmark = [pytest.mark.integration] + + +def _get_type(tools, topic): + """Get the msg type for a topic, failing the test with a clear message if not found.""" + result = tools["get_topic_type"](topic=topic) + assert "type" in result, f"get_topic_type failed for {topic}: {result}" + return result["type"] + + +class TestGetTopics: + """Verify get_topics MCP tool returns the topic list.""" + + def test_returns_topics(self, tools): + """get_topics should return topics, types, and topic_count.""" + result = tools["get_topics"]() + assert "topics" in result + assert "types" in result + assert "topic_count" in result + assert result["topic_count"] > 0 + assert result["topic_count"] == len(result["topics"]) + assert len(result["types"]) == len(result["topics"]) + + def test_includes_turtle_pose(self, tools): + """turtlesim publishes /turtle1/pose โ€” it should appear in topics.""" + result = tools["get_topics"]() + assert any("/turtle1/pose" in t for t in result["topics"]), ( + f"/turtle1/pose not in {result['topics']}" + ) + + def test_includes_cmd_vel(self, tools): + """turtlesim subscribes to /turtle1/cmd_vel โ€” it should appear in topics.""" + result = tools["get_topics"]() + assert any("cmd_vel" in t for t in result["topics"]), f"cmd_vel not in {result['topics']}" + + +class TestGetTopicType: + """Verify get_topic_type MCP tool returns the message type.""" + + def test_turtle_pose_type(self, tools): + """get_topic_type for /turtle1/pose should return turtlesim/Pose.""" + result = tools["get_topic_type"](topic="/turtle1/pose") + assert "type" in result + assert "Pose" in result["type"] + + def test_empty_topic_returns_error(self, tools): + """get_topic_type with empty string should return error.""" + result = tools["get_topic_type"](topic="") + assert "error" in result + + +class TestGetTopicDetails: + """Verify get_topic_details MCP tool returns type + publishers + subscribers.""" + + def test_pose_details(self, tools): + """get_topic_details for /turtle1/pose should have turtlesim as publisher.""" + result = tools["get_topic_details"](topic="/turtle1/pose") + assert result["topic"] == "/turtle1/pose" + assert "Pose" in result["type"] + assert result["publisher_count"] > 0 + assert any("turtlesim" in p for p in result["publishers"]), result["publishers"] + + def test_empty_topic_returns_error(self, tools): + """get_topic_details with empty string should return error.""" + result = tools["get_topic_details"](topic="") + assert "error" in result + + +class TestGetMessageDetails: + """Verify get_message_details MCP tool returns message structure.""" + + def test_twist_structure(self, tools): + """get_message_details for geometry_msgs/Twist should return fields.""" + result = tools["get_message_details"](message_type="geometry_msgs/Twist") + assert "structure" in result, f"Unexpected result: {result}" + assert len(result["structure"]) > 0 + + def test_empty_type_returns_error(self, tools): + """get_message_details with empty string should return error.""" + result = tools["get_message_details"](message_type="") + assert "error" in result + + +class TestSubscribeOnce: + """Verify subscribe_once MCP tool receives a message from a live topic.""" + + def test_subscribe_to_pose(self, tools): + """subscribe_once on /turtle1/pose should receive a Pose message.""" + pose_type = _get_type(tools, "/turtle1/pose") + result = tools["subscribe_once"]( + topic="/turtle1/pose", + msg_type=pose_type, + timeout=5.0, + ) + assert "msg" in result, f"subscribe_once failed: {result}" + msg = result["msg"] + assert "x" in msg + assert "y" in msg + + def test_missing_args_returns_error(self, tools): + """subscribe_once without topic/msg_type should return error.""" + result = tools["subscribe_once"]() + assert "error" in result + + +class TestSubscribeForDuration: + """Verify subscribe_for_duration MCP tool collects messages over time.""" + + def test_collect_pose_messages(self, tools): + """subscribe_for_duration on /turtle1/pose should collect multiple messages.""" + pose_type = _get_type(tools, "/turtle1/pose") + result = tools["subscribe_for_duration"]( + topic="/turtle1/pose", + msg_type=pose_type, + duration=2.0, + max_messages=5, + ) + # subscribe_for_duration returns a ToolResult; summary is in content[0].text + assert hasattr(result, "content"), f"subscribe_for_duration failed: {result}" + summary = json.loads(result.content[0].text) + assert summary["collected_count"] > 0 + assert summary["topic"] == "/turtle1/pose" + + def test_missing_args_returns_error(self, tools): + """subscribe_for_duration without topic/msg_type should return error.""" + result = tools["subscribe_for_duration"]() + assert "error" in result + + +class TestPublishOnce: + """Verify publish_once MCP tool sends a message.""" + + def test_publish_cmd_vel(self, tools): + """publish_once should successfully publish a Twist to /turtle1/cmd_vel.""" + cmd_vel_type = _get_type(tools, "/turtle1/cmd_vel") + result = tools["publish_once"]( + topic="/turtle1/cmd_vel", + msg_type=cmd_vel_type, + msg={"linear": {"x": 0.1, "y": 0.0, "z": 0.0}}, + ) + assert result.get("success") is True, f"publish_once failed: {result}" + + def test_empty_msg_returns_error(self, tools): + """publish_once with empty msg should return error.""" + result = tools["publish_once"]( + topic="/turtle1/cmd_vel", + msg_type="geometry_msgs/Twist", + msg={}, + ) + assert "error" in result + + def test_missing_topic_returns_error(self, tools): + """publish_once without topic should return error.""" + result = tools["publish_once"]() + assert "error" in result + + +class TestPublishForDurations: + """Verify publish_for_durations MCP tool sends a sequence of messages.""" + + def test_publish_sequence(self, tools): + """publish_for_durations should publish a message sequence.""" + cmd_vel_type = _get_type(tools, "/turtle1/cmd_vel") + messages = [{"linear": {"x": 0.1}}, {"linear": {"x": 0.0}}] + result = tools["publish_for_durations"]( + topic="/turtle1/cmd_vel", + msg_type=cmd_vel_type, + messages=messages, + durations=[0.5, 0.1], + rate_hz=0, + ) + assert result.get("success") is True, f"publish_for_durations failed: {result}" + # published_count may be < total on some distros due to rosbridge response + # timing in non-streaming mode (rate_hz=0), but it must never exceed the + # number of messages requested. + published = result["published_count"] + assert 1 <= published <= len(messages), ( + f"expected 1..{len(messages)} published, got {published}: {result}" + ) + + def test_empty_sequence(self, tools): + """publish_for_durations with empty lists should succeed with 0 published.""" + cmd_vel_type = _get_type(tools, "/turtle1/cmd_vel") + result = tools["publish_for_durations"]( + topic="/turtle1/cmd_vel", + msg_type=cmd_vel_type, + messages=[], + durations=[], + ) + assert result.get("success") is True + assert result["published_count"] == 0 + + def test_missing_args_returns_error(self, tools): + """publish_for_durations without topic/msg_type should return error.""" + result = tools["publish_for_durations"]() + assert "error" in result + + +class TestPublishAndSubscribe: + """End-to-end: publish cmd_vel and verify turtle moves via pose subscription.""" + + def test_turtle_moves_after_publish(self, tools): + """Publish a velocity, then check that the turtle's position changed.""" + pose_type = _get_type(tools, "/turtle1/pose") + + # Get initial pose + before = tools["subscribe_once"](topic="/turtle1/pose", msg_type=pose_type, timeout=5.0) + assert "msg" in before, f"Failed to get initial pose: {before}" + x_before = before["msg"]["x"] + + # Publish forward velocity + cmd_vel_type = _get_type(tools, "/turtle1/cmd_vel") + pub_result = tools["publish_for_durations"]( + topic="/turtle1/cmd_vel", + msg_type=cmd_vel_type, + messages=[{"linear": {"x": 1.0, "y": 0.0, "z": 0.0}}], + durations=[1.0], + rate_hz=10, + ) + assert pub_result.get("success") is True + + # Wait briefly for physics to update + time.sleep(0.5) + + # Get new pose + after = tools["subscribe_once"](topic="/turtle1/pose", msg_type=pose_type, timeout=5.0) + assert "msg" in after, f"Failed to get new pose: {after}" + x_after = after["msg"]["x"] + + # Forward velocity (linear.x > 0) should increase x specifically. + assert x_after > x_before, ( + f"Turtle did not move forward: x_before={x_before}, x_after={x_after}" + ) diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 0000000..0ee4964 --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,26 @@ +"""Shared fixtures for unit tests.""" + +import pytest + +# --------------------------------------------------------------------------- +# Robot config fixtures (used by test_config_utils.py) +# --------------------------------------------------------------------------- + + +@pytest.fixture +def valid_robot_yaml(tmp_path): + """Create a temporary valid robot YAML file and return (specs_dir, robot_name).""" + yaml_content = ( + "name: test_robot\ntype: simulated\nprompts: |\n This is a test robot for unit testing.\n" + ) + yaml_file = tmp_path / "test_robot.yaml" + yaml_file.write_text(yaml_content) + return str(tmp_path), "test_robot" + + +@pytest.fixture +def empty_robot_yaml(tmp_path): + """Create a temporary empty YAML file and return (specs_dir, robot_name).""" + yaml_file = tmp_path / "empty_robot.yaml" + yaml_file.write_text("") + return str(tmp_path), "empty_robot" diff --git a/tests/unit/test_config_utils.py b/tests/unit/test_config_utils.py new file mode 100644 index 0000000..1bcc5db --- /dev/null +++ b/tests/unit/test_config_utils.py @@ -0,0 +1,138 @@ +"""Unit tests for ros_mcp/utils/config_utils.py.""" + +from pathlib import Path + +import pytest + +import ros_mcp.utils.config_utils as config_module +from ros_mcp.utils.config_utils import ( + get_verified_robot_spec_util, + get_verified_robots_list_util, + load_robot_config, +) + +# --------------------------------------------------------------------------- +# load_robot_config โ€” accepts specs_dir param, fully controllable +# --------------------------------------------------------------------------- + + +class TestLoadRobotConfig: + def test_valid_yaml(self, valid_robot_yaml): + specs_dir, robot_name = valid_robot_yaml + config = load_robot_config(robot_name, specs_dir) + assert config["name"] == "test_robot" + assert config["type"] == "simulated" + assert "test robot" in config["prompts"].lower() + + def test_missing_file_raises(self, tmp_path): + with pytest.raises(FileNotFoundError, match="Robot config file not found"): + load_robot_config("nonexistent", str(tmp_path)) + + def test_empty_yaml_returns_empty_dict(self, empty_robot_yaml): + specs_dir, robot_name = empty_robot_yaml + config = load_robot_config(robot_name, specs_dir) + assert config == {} + + +# --------------------------------------------------------------------------- +# get_verified_robot_spec_util โ€” uses Path(__file__) internally +# --------------------------------------------------------------------------- + + +def _patch_specs_dir(monkeypatch, tmp_path): + """Redirect config_utils.__file__ so specs_dir resolves to tmp_path/robot_specifications. + + Note: this approach is tightly coupled to config_utils's internal path + resolution (Path(__file__).parent.parent.parent / "robot_specifications"). + If config_utils is refactored to accept specs_dir via injection or + configuration, these monkeypatches will need to be revisited. + """ + # config_utils resolves: Path(__file__).parent.parent.parent / "robot_specifications" + # So __file__ must be at: tmp_path / "a" / "b" / "config_utils.py" + fake_file = tmp_path / "a" / "b" / "config_utils.py" + fake_file.parent.mkdir(parents=True, exist_ok=True) + monkeypatch.setattr(config_module, "__file__", str(fake_file)) + + +class TestGetVerifiedRobotSpecUtil: + def test_valid_robot_with_fixture(self, monkeypatch, tmp_path): + _patch_specs_dir(monkeypatch, tmp_path) + specs_dir = tmp_path / "robot_specifications" + specs_dir.mkdir() + (specs_dir / "my_robot.yaml").write_text("type: simulated\nprompts: A test robot.\n") + result = get_verified_robot_spec_util("my_robot") + assert "my_robot" in result + assert result["my_robot"]["type"] == "simulated" + + def test_spaces_in_name_replaced(self, monkeypatch, tmp_path): + _patch_specs_dir(monkeypatch, tmp_path) + specs_dir = tmp_path / "robot_specifications" + specs_dir.mkdir() + (specs_dir / "my_robot.yaml").write_text("type: real\nprompts: A real robot.\n") + result = get_verified_robot_spec_util("my robot") + assert "my_robot" in result + + def test_missing_robot_raises(self, monkeypatch, tmp_path): + _patch_specs_dir(monkeypatch, tmp_path) + (tmp_path / "robot_specifications").mkdir() + with pytest.raises(FileNotFoundError): + get_verified_robot_spec_util("nonexistent") + + def test_empty_config_raises_value_error(self, monkeypatch, tmp_path): + _patch_specs_dir(monkeypatch, tmp_path) + specs_dir = tmp_path / "robot_specifications" + specs_dir.mkdir() + (specs_dir / "empty.yaml").write_text("") + with pytest.raises(ValueError, match="No configuration found"): + get_verified_robot_spec_util("empty") + + def test_missing_required_field_raises(self, monkeypatch, tmp_path): + _patch_specs_dir(monkeypatch, tmp_path) + specs_dir = tmp_path / "robot_specifications" + specs_dir.mkdir() + (specs_dir / "incomplete.yaml").write_text("type: real\n") + with pytest.raises(ValueError, match="missing required field"): + get_verified_robot_spec_util("incomplete") + + def test_with_real_robot_specs(self): + """Integration-style: test against actual robot_specifications/ in the repo.""" + specs_path = Path(__file__).parent.parent.parent / "robot_specifications" + real_robots = [f.stem for f in specs_path.glob("*.yaml") if f.stem != "YOUR_ROBOT_NAME"] + if not real_robots: + pytest.skip("No real robot spec files found") + result = get_verified_robot_spec_util(real_robots[0]) + assert real_robots[0] in result + assert "type" in result[real_robots[0]] + assert "prompts" in result[real_robots[0]] + + +# --------------------------------------------------------------------------- +# get_verified_robots_list_util โ€” uses Path(__file__) internally +# --------------------------------------------------------------------------- + + +class TestGetVerifiedRobotsListUtil: + def test_returns_sorted_list(self): + """Test against the actual robot_specifications/ directory.""" + result = get_verified_robots_list_util() + if "error" in result: + pytest.skip(f"Skipping: {result['error']}") + assert "robot_specifications" in result + assert "count" in result + assert result["count"] > 0 + names = result["robot_specifications"] + assert names == sorted(names) + + def test_missing_directory(self, monkeypatch, tmp_path): + _patch_specs_dir(monkeypatch, tmp_path) + # Don't create robot_specifications/ โ†’ should return error + result = get_verified_robots_list_util() + assert "error" in result + assert "not found" in result["error"] + + def test_empty_directory(self, monkeypatch, tmp_path): + _patch_specs_dir(monkeypatch, tmp_path) + (tmp_path / "robot_specifications").mkdir() + result = get_verified_robots_list_util() + assert "error" in result + assert "No robot specification files found" in result["error"] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..4c78bf8 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1981 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[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.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "authlib" +version = "1.6.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/3f/1d3bbd0bf23bdd99276d4def22f29c27a914067b4cf66f753ff9b8bbd0f3/authlib-1.6.5.tar.gz", hash = "sha256:6aaf9c79b7cc96c900f0b284061691c5d4e61221640a948fe690b556a6d6d10b", size = 164553, upload-time = "2025-10-02T13:36:09.489Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/aa/5082412d1ee302e9e7d80b6949bc4d2a8fa1149aaab610c5fc24709605d6/authlib-1.6.5-py2.py3-none-any.whl", hash = "sha256:3e0e0507807f842b02175507bdee8957a1d5707fd4afb17c32fb43fee90b6e3a", size = 243608, upload-time = "2025-10-02T13:36:07.637Z" }, +] + +[[package]] +name = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, +] + +[[package]] +name = "beartype" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/77/af43bdf737723b28130f2cb595ec0f23e0e757d211fe068fd0ccdb77d786/beartype-0.22.4.tar.gz", hash = "sha256:68284c7803efd190b1b4639a0ab1a17677af9571b8a2ef5a169d10cb8955b01f", size = 1578210, upload-time = "2025-10-26T03:30:50.352Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/eb/f25ad1a7726b2fe21005c3580b35fa7bfe09646faf7c8f41867747987a35/beartype-0.22.4-py3-none-any.whl", hash = "sha256:7967a1cee01fee42e47da69c58c92da10ba5bcfb8072686e48487be5201e3d10", size = 1318387, upload-time = "2025-10-26T03:30:48.135Z" }, +] + +[[package]] +name = "cachetools" +version = "6.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/7e/b975b5814bd36faf009faebe22c1072a1fa1168db34d285ef0ba071ad78c/cachetools-6.2.1.tar.gz", hash = "sha256:3f391e4bd8f8bf0931169baf7456cc822705f4e2a31f840d218f445b9a854201", size = 31325, upload-time = "2025-10-12T14:55:30.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/c5/1e741d26306c42e2bf6ab740b2202872727e0f606033c9dd713f8b93f5a8/cachetools-6.2.1-py3-none-any.whl", hash = "sha256:09868944b6dde876dfd44e1d47e18484541eaf12f26f29b7af91b26cc892d701", size = 11280, upload-time = "2025-10-12T14:55:28.382Z" }, +] + +[[package]] +name = "certifi" +version = "2025.10.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, + { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, + { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, + { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, + { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, + { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, + { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, + { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, + { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, +] + +[[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 = "cryptography" +version = "46.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" }, + { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, + { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, + { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, + { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, + { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, + { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, + { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, + { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" }, + { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" }, + { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" }, + { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" }, + { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" }, + { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" }, + { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" }, + { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" }, + { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" }, + { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, + { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, + { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, + { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, + { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, + { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, + { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" }, + { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cd/1a8633802d766a0fa46f382a77e096d7e209e0817892929655fe0586ae32/cryptography-46.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32", size = 3689163, upload-time = "2025-10-15T23:18:13.821Z" }, + { url = "https://files.pythonhosted.org/packages/4c/59/6b26512964ace6480c3e54681a9859c974172fb141c38df11eadd8416947/cryptography-46.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c", size = 3429474, upload-time = "2025-10-15T23:18:15.477Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/e60e46adab4362a682cf142c7dcb5bf79b782ab2199b0dcb81f55970807f/cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea", size = 3698132, upload-time = "2025-10-15T23:18:17.056Z" }, + { url = "https://files.pythonhosted.org/packages/da/38/f59940ec4ee91e93d3311f7532671a5cef5570eb04a144bf203b58552d11/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b", size = 4243992, upload-time = "2025-10-15T23:18:18.695Z" }, + { url = "https://files.pythonhosted.org/packages/b0/0c/35b3d92ddebfdfda76bb485738306545817253d0a3ded0bfe80ef8e67aa5/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb", size = 4409944, upload-time = "2025-10-15T23:18:20.597Z" }, + { url = "https://files.pythonhosted.org/packages/99/55/181022996c4063fc0e7666a47049a1ca705abb9c8a13830f074edb347495/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717", size = 4242957, upload-time = "2025-10-15T23:18:22.18Z" }, + { url = "https://files.pythonhosted.org/packages/ba/af/72cd6ef29f9c5f731251acadaeb821559fe25f10852f44a63374c9ca08c1/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9", size = 4409447, upload-time = "2025-10-15T23:18:24.209Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c3/e90f4a4feae6410f914f8ebac129b9ae7a8c92eb60a638012dde42030a9d/cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c", size = 3438528, upload-time = "2025-10-15T23:18:26.227Z" }, +] + +[[package]] +name = "cyclopts" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "docstring-parser" }, + { name = "rich" }, + { name = "rich-rst" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/d1/2f2b99ec5ea54ac18baadfc4a011e2a1743c1eaae1e39838ca520dcf4811/cyclopts-4.0.0.tar.gz", hash = "sha256:0dae712085e91d32cc099ea3d78f305b0100a3998b1dec693be9feb0b1be101f", size = 143546, upload-time = "2025-10-20T18:33:01.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/0e/0a22e076944600aeb06f40b7e03bbd762a42d56d43a2f5f4ab954aed9005/cyclopts-4.0.0-py3-none-any.whl", hash = "sha256:e64801a2c86b681f08323fd50110444ee961236a0bae402a66d2cc3feda33da7", size = 178837, upload-time = "2025-10-20T18:33:00.191Z" }, +] + +[[package]] +name = "diskcache" +version = "5.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/21/1c1ffc1a039ddcc459db43cc108658f32c57d271d7289a2794e401d0fdb6/diskcache-5.6.3.tar.gz", hash = "sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc", size = 67916, upload-time = "2023-08-31T06:12:00.316Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19", size = 45550, upload-time = "2023-08-31T06:11:58.822Z" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, +] + +[[package]] +name = "docutils" +version = "0.22.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/c0/89fe6215b443b919cb98a5002e107cb5026854ed1ccb6b5833e0768419d1/docutils-0.22.2.tar.gz", hash = "sha256:9fdb771707c8784c8f2728b67cb2c691305933d68137ef95a75db5f4dfbc213d", size = 2289092, upload-time = "2025-09-20T17:55:47.994Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/dd/f95350e853a4468ec37478414fc04ae2d61dad7a947b3015c3dcc51a09b9/docutils-0.22.2-py3-none-any.whl", hash = "sha256:b0e98d679283fc3bb0ead8a5da7f501baa632654e7056e9c5846842213d674d8", size = 632667, upload-time = "2025-09-20T17:55:43.052Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, +] + +[[package]] +name = "fastmcp" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "authlib" }, + { name = "cyclopts" }, + { name = "exceptiongroup" }, + { name = "httpx" }, + { name = "mcp" }, + { name = "openapi-core" }, + { name = "openapi-pydantic" }, + { name = "platformdirs" }, + { name = "py-key-value-aio", extra = ["disk", "keyring", "memory"] }, + { name = "pydantic", extra = ["email"] }, + { name = "pyperclip" }, + { name = "python-dotenv" }, + { name = "rich" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/3b/c30af894db2c3ec439d0e4168ba7ce705474cabdd0a599033ad9a19ad977/fastmcp-2.13.0.tar.gz", hash = "sha256:57f7b7503363e1babc0d1a13af18252b80366a409e1de85f1256cce66a4bee35", size = 7767346, upload-time = "2025-10-25T12:54:10.957Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/7f/09942135f506953fc61bb81b9e5eaf50a8eea923b83d9135bd959168ef2d/fastmcp-2.13.0-py3-none-any.whl", hash = "sha256:bdff1399d3b7ebb79286edfd43eb660182432514a5ab8e4cbfb45f1d841d2aa0", size = 367134, upload-time = "2025-10-25T12:54:09.284Z" }, +] + +[[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 = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "isodate" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705, upload-time = "2024-10-08T23:04:11.5Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" }, +] + +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/ad/f3777b81bf0b6e7bc7514a1656d3e637b2e8e15fab2ce3235730b3e7a4e6/jaraco_context-6.0.1.tar.gz", hash = "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3", size = 13912, upload-time = "2024-08-20T03:39:27.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/db/0c52c4cf5e4bd9f5d7135ec7669a3a767af21b3a308e1ed3674881e52b62/jaraco.context-6.0.1-py3-none-any.whl", hash = "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4", size = 6825, upload-time = "2024-08-20T03:39:25.966Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/ed/1aa2d585304ec07262e1a83a9889880701079dde796ac7b1d1826f40c63d/jaraco_functools-4.3.0.tar.gz", hash = "sha256:cfd13ad0dd2c47a3600b439ef72d8615d482cedcff1632930d6f28924d92f294", size = 19755, upload-time = "2025-08-18T20:05:09.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/09/726f168acad366b11e420df31bf1c702a54d373a83f968d94141a8c3fde0/jaraco_functools-4.3.0-py3-none-any.whl", hash = "sha256:227ff8ed6f7b8f62c56deff101545fa7543cf2c8e7b82a7c2116e672f29c26e8", size = 10408, upload-time = "2025-08-18T20:05:08.69Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, +] + +[[package]] +name = "jsonschema-path" +version = "0.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pathable" }, + { name = "pyyaml" }, + { name = "referencing" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/45/41ebc679c2a4fced6a722f624c18d658dee42612b83ea24c1caf7c0eb3a8/jsonschema_path-0.3.4.tar.gz", hash = "sha256:8365356039f16cc65fddffafda5f58766e34bebab7d6d105616ab52bc4297001", size = 11159, upload-time = "2025-01-24T14:33:16.547Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/58/3485da8cb93d2f393bce453adeef16896751f14ba3e2024bc21dc9597646/jsonschema_path-0.3.4-py3-none-any.whl", hash = "sha256:f502191fdc2b22050f9a81c9237be9d27145b9001c55842bece5e94e382e52f8", size = 14810, upload-time = "2025-01-24T14:33:14.652Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "keyring" +version = "25.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/09/d904a6e96f76ff214be59e7aa6ef7190008f52a0ab6689760a98de0bf37d/keyring-25.6.0.tar.gz", hash = "sha256:0b39998aa941431eb3d9b0d4b2460bc773b9df6fed7621c2dfb291a7e0187a66", size = 62750, upload-time = "2024-12-25T15:26:45.782Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/32/da7f44bcb1105d3e88a0b74ebdca50c59121d2ddf71c9e34ba47df7f3a56/keyring-25.6.0-py3-none-any.whl", hash = "sha256:552a3f7af126ece7ed5c89753650eec89c7eaae8617d0aa4d9ad2b75111266bd", size = 39085, upload-time = "2024-12-25T15:26:44.377Z" }, +] + +[[package]] +name = "lazy-object-proxy" +version = "1.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/a2/69df9c6ba6d316cfd81fe2381e464db3e6de5db45f8c43c6a23504abf8cb/lazy_object_proxy-1.12.0.tar.gz", hash = "sha256:1f5a462d92fd0cfb82f1fab28b51bfb209fabbe6aabf7f0d51472c0c124c0c61", size = 43681, upload-time = "2025-08-22T13:50:06.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/2b/d5e8915038acbd6c6a9fcb8aaf923dc184222405d3710285a1fec6e262bc/lazy_object_proxy-1.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:61d5e3310a4aa5792c2b599a7a78ccf8687292c8eb09cf187cca8f09cf6a7519", size = 26658, upload-time = "2025-08-22T13:42:23.373Z" }, + { url = "https://files.pythonhosted.org/packages/da/8f/91fc00eeea46ee88b9df67f7c5388e60993341d2a406243d620b2fdfde57/lazy_object_proxy-1.12.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1ca33565f698ac1aece152a10f432415d1a2aa9a42dfe23e5ba2bc255ab91f6", size = 68412, upload-time = "2025-08-22T13:42:24.727Z" }, + { url = "https://files.pythonhosted.org/packages/07/d2/b7189a0e095caedfea4d42e6b6949d2685c354263bdf18e19b21ca9b3cd6/lazy_object_proxy-1.12.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d01c7819a410f7c255b20799b65d36b414379a30c6f1684c7bd7eb6777338c1b", size = 67559, upload-time = "2025-08-22T13:42:25.875Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/b013840cc43971582ff1ceaf784d35d3a579650eb6cc348e5e6ed7e34d28/lazy_object_proxy-1.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:029d2b355076710505c9545aef5ab3f750d89779310e26ddf2b7b23f6ea03cd8", size = 66651, upload-time = "2025-08-22T13:42:27.427Z" }, + { url = "https://files.pythonhosted.org/packages/7e/6f/b7368d301c15612fcc4cd00412b5d6ba55548bde09bdae71930e1a81f2ab/lazy_object_proxy-1.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc6e3614eca88b1c8a625fc0a47d0d745e7c3255b21dac0e30b3037c5e3deeb8", size = 66901, upload-time = "2025-08-22T13:42:28.585Z" }, + { url = "https://files.pythonhosted.org/packages/61/1b/c6b1865445576b2fc5fa0fbcfce1c05fee77d8979fd1aa653dd0f179aefc/lazy_object_proxy-1.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:be5fe974e39ceb0d6c9db0663c0464669cf866b2851c73971409b9566e880eab", size = 26536, upload-time = "2025-08-22T13:42:29.636Z" }, + { url = "https://files.pythonhosted.org/packages/01/b3/4684b1e128a87821e485f5a901b179790e6b5bc02f89b7ee19c23be36ef3/lazy_object_proxy-1.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1cf69cd1a6c7fe2dbcc3edaa017cf010f4192e53796538cc7d5e1fedbfa4bcff", size = 26656, upload-time = "2025-08-22T13:42:30.605Z" }, + { url = "https://files.pythonhosted.org/packages/3a/03/1bdc21d9a6df9ff72d70b2ff17d8609321bea4b0d3cffd2cea92fb2ef738/lazy_object_proxy-1.12.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:efff4375a8c52f55a145dc8487a2108c2140f0bec4151ab4e1843e52eb9987ad", size = 68832, upload-time = "2025-08-22T13:42:31.675Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4b/5788e5e8bd01d19af71e50077ab020bc5cce67e935066cd65e1215a09ff9/lazy_object_proxy-1.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1192e8c2f1031a6ff453ee40213afa01ba765b3dc861302cd91dbdb2e2660b00", size = 69148, upload-time = "2025-08-22T13:42:32.876Z" }, + { url = "https://files.pythonhosted.org/packages/79/0e/090bf070f7a0de44c61659cb7f74c2fe02309a77ca8c4b43adfe0b695f66/lazy_object_proxy-1.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3605b632e82a1cbc32a1e5034278a64db555b3496e0795723ee697006b980508", size = 67800, upload-time = "2025-08-22T13:42:34.054Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d2/b320325adbb2d119156f7c506a5fbfa37fcab15c26d13cf789a90a6de04e/lazy_object_proxy-1.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a61095f5d9d1a743e1e20ec6d6db6c2ca511961777257ebd9b288951b23b44fa", size = 68085, upload-time = "2025-08-22T13:42:35.197Z" }, + { url = "https://files.pythonhosted.org/packages/6a/48/4b718c937004bf71cd82af3713874656bcb8d0cc78600bf33bb9619adc6c/lazy_object_proxy-1.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:997b1d6e10ecc6fb6fe0f2c959791ae59599f41da61d652f6c903d1ee58b7370", size = 26535, upload-time = "2025-08-22T13:42:36.521Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1b/b5f5bd6bda26f1e15cd3232b223892e4498e34ec70a7f4f11c401ac969f1/lazy_object_proxy-1.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ee0d6027b760a11cc18281e702c0309dd92da458a74b4c15025d7fc490deede", size = 26746, upload-time = "2025-08-22T13:42:37.572Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/314889b618075c2bfc19293ffa9153ce880ac6153aacfd0a52fcabf21a66/lazy_object_proxy-1.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4ab2c584e3cc8be0dfca422e05ad30a9abe3555ce63e9ab7a559f62f8dbc6ff9", size = 71457, upload-time = "2025-08-22T13:42:38.743Z" }, + { url = "https://files.pythonhosted.org/packages/11/53/857fc2827fc1e13fbdfc0ba2629a7d2579645a06192d5461809540b78913/lazy_object_proxy-1.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14e348185adbd03ec17d051e169ec45686dcd840a3779c9d4c10aabe2ca6e1c0", size = 71036, upload-time = "2025-08-22T13:42:40.184Z" }, + { url = "https://files.pythonhosted.org/packages/2b/24/e581ffed864cd33c1b445b5763d617448ebb880f48675fc9de0471a95cbc/lazy_object_proxy-1.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c4fcbe74fb85df8ba7825fa05eddca764138da752904b378f0ae5ab33a36c308", size = 69329, upload-time = "2025-08-22T13:42:41.311Z" }, + { url = "https://files.pythonhosted.org/packages/78/be/15f8f5a0b0b2e668e756a152257d26370132c97f2f1943329b08f057eff0/lazy_object_proxy-1.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:563d2ec8e4d4b68ee7848c5ab4d6057a6d703cb7963b342968bb8758dda33a23", size = 70690, upload-time = "2025-08-22T13:42:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/5d/aa/f02be9bbfb270e13ee608c2b28b8771f20a5f64356c6d9317b20043c6129/lazy_object_proxy-1.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:53c7fd99eb156bbb82cbc5d5188891d8fdd805ba6c1e3b92b90092da2a837073", size = 26563, upload-time = "2025-08-22T13:42:43.685Z" }, + { url = "https://files.pythonhosted.org/packages/f4/26/b74c791008841f8ad896c7f293415136c66cc27e7c7577de4ee68040c110/lazy_object_proxy-1.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:86fd61cb2ba249b9f436d789d1356deae69ad3231dc3c0f17293ac535162672e", size = 26745, upload-time = "2025-08-22T13:42:44.982Z" }, + { url = "https://files.pythonhosted.org/packages/9b/52/641870d309e5d1fb1ea7d462a818ca727e43bfa431d8c34b173eb090348c/lazy_object_proxy-1.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:81d1852fb30fab81696f93db1b1e55a5d1ff7940838191062f5f56987d5fcc3e", size = 71537, upload-time = "2025-08-22T13:42:46.141Z" }, + { url = "https://files.pythonhosted.org/packages/47/b6/919118e99d51c5e76e8bf5a27df406884921c0acf2c7b8a3b38d847ab3e9/lazy_object_proxy-1.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9045646d83f6c2664c1330904b245ae2371b5c57a3195e4028aedc9f999655", size = 71141, upload-time = "2025-08-22T13:42:47.375Z" }, + { url = "https://files.pythonhosted.org/packages/e5/47/1d20e626567b41de085cf4d4fb3661a56c159feaa73c825917b3b4d4f806/lazy_object_proxy-1.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:67f07ab742f1adfb3966c40f630baaa7902be4222a17941f3d85fd1dae5565ff", size = 69449, upload-time = "2025-08-22T13:42:48.49Z" }, + { url = "https://files.pythonhosted.org/packages/58/8d/25c20ff1a1a8426d9af2d0b6f29f6388005fc8cd10d6ee71f48bff86fdd0/lazy_object_proxy-1.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75ba769017b944fcacbf6a80c18b2761a1795b03f8899acdad1f1c39db4409be", size = 70744, upload-time = "2025-08-22T13:42:49.608Z" }, + { url = "https://files.pythonhosted.org/packages/c0/67/8ec9abe15c4f8a4bcc6e65160a2c667240d025cbb6591b879bea55625263/lazy_object_proxy-1.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:7b22c2bbfb155706b928ac4d74c1a63ac8552a55ba7fff4445155523ea4067e1", size = 26568, upload-time = "2025-08-22T13:42:57.719Z" }, + { url = "https://files.pythonhosted.org/packages/23/12/cd2235463f3469fd6c62d41d92b7f120e8134f76e52421413a0ad16d493e/lazy_object_proxy-1.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4a79b909aa16bde8ae606f06e6bbc9d3219d2e57fb3e0076e17879072b742c65", size = 27391, upload-time = "2025-08-22T13:42:50.62Z" }, + { url = "https://files.pythonhosted.org/packages/60/9e/f1c53e39bbebad2e8609c67d0830cc275f694d0ea23d78e8f6db526c12d3/lazy_object_proxy-1.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:338ab2f132276203e404951205fe80c3fd59429b3a724e7b662b2eb539bb1be9", size = 80552, upload-time = "2025-08-22T13:42:51.731Z" }, + { url = "https://files.pythonhosted.org/packages/4c/b6/6c513693448dcb317d9d8c91d91f47addc09553613379e504435b4cc8b3e/lazy_object_proxy-1.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c40b3c9faee2e32bfce0df4ae63f4e73529766893258eca78548bac801c8f66", size = 82857, upload-time = "2025-08-22T13:42:53.225Z" }, + { url = "https://files.pythonhosted.org/packages/12/1c/d9c4aaa4c75da11eb7c22c43d7c90a53b4fca0e27784a5ab207768debea7/lazy_object_proxy-1.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:717484c309df78cedf48396e420fa57fc8a2b1f06ea889df7248fdd156e58847", size = 80833, upload-time = "2025-08-22T13:42:54.391Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ae/29117275aac7d7d78ae4f5a4787f36ff33262499d486ac0bf3e0b97889f6/lazy_object_proxy-1.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a6b7ea5ea1ffe15059eb44bcbcb258f97bcb40e139b88152c40d07b1a1dfc9ac", size = 79516, upload-time = "2025-08-22T13:42:55.812Z" }, + { url = "https://files.pythonhosted.org/packages/19/40/b4e48b2c38c69392ae702ae7afa7b6551e0ca5d38263198b7c79de8b3bdf/lazy_object_proxy-1.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:08c465fb5cd23527512f9bd7b4c7ba6cec33e28aad36fbbe46bf7b858f9f3f7f", size = 27656, upload-time = "2025-08-22T13:42:56.793Z" }, + { url = "https://files.pythonhosted.org/packages/ef/3a/277857b51ae419a1574557c0b12e0d06bf327b758ba94cafc664cb1e2f66/lazy_object_proxy-1.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c9defba70ab943f1df98a656247966d7729da2fe9c2d5d85346464bf320820a3", size = 26582, upload-time = "2025-08-22T13:49:49.366Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b6/c5e0fa43535bb9c87880e0ba037cdb1c50e01850b0831e80eb4f4762f270/lazy_object_proxy-1.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6763941dbf97eea6b90f5b06eb4da9418cc088fce0e3883f5816090f9afcde4a", size = 71059, upload-time = "2025-08-22T13:49:50.488Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/7dcad19c685963c652624702f1a968ff10220b16bfcc442257038216bf55/lazy_object_proxy-1.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fdc70d81235fc586b9e3d1aeef7d1553259b62ecaae9db2167a5d2550dcc391a", size = 71034, upload-time = "2025-08-22T13:49:54.224Z" }, + { url = "https://files.pythonhosted.org/packages/12/ac/34cbfb433a10e28c7fd830f91c5a348462ba748413cbb950c7f259e67aa7/lazy_object_proxy-1.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0a83c6f7a6b2bfc11ef3ed67f8cbe99f8ff500b05655d8e7df9aab993a6abc95", size = 69529, upload-time = "2025-08-22T13:49:55.29Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6a/11ad7e349307c3ca4c0175db7a77d60ce42a41c60bcb11800aabd6a8acb8/lazy_object_proxy-1.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:256262384ebd2a77b023ad02fbcc9326282bcfd16484d5531154b02bc304f4c5", size = 70391, upload-time = "2025-08-22T13:49:56.35Z" }, + { url = "https://files.pythonhosted.org/packages/59/97/9b410ed8fbc6e79c1ee8b13f8777a80137d4bc189caf2c6202358e66192c/lazy_object_proxy-1.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7601ec171c7e8584f8ff3f4e440aa2eebf93e854f04639263875b8c2971f819f", size = 26988, upload-time = "2025-08-22T13:49:57.302Z" }, + { url = "https://files.pythonhosted.org/packages/41/a0/b91504515c1f9a299fc157967ffbd2f0321bce0516a3d5b89f6f4cad0355/lazy_object_proxy-1.12.0-pp39.pp310.pp311.graalpy311-none-any.whl", hash = "sha256:c3b2e0af1f7f77c4263759c4824316ce458fabe0fceadcd24ef8ca08b2d1e402", size = 15072, upload-time = "2025-08-22T13:50:05.498Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mcp" +version = "1.19.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/2b/916852a5668f45d8787378461eaa1244876d77575ffef024483c94c0649c/mcp-1.19.0.tar.gz", hash = "sha256:213de0d3cd63f71bc08ffe9cc8d4409cc87acffd383f6195d2ce0457c021b5c1", size = 444163, upload-time = "2025-10-24T01:11:15.839Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/a3/3e71a875a08b6a830b88c40bc413bff01f1650f1efe8a054b5e90a9d4f56/mcp-1.19.0-py3-none-any.whl", hash = "sha256:f5907fe1c0167255f916718f376d05f09a830a215327a3ccdd5ec8a519f2e572", size = 170105, upload-time = "2025-10-24T01:11:14.151Z" }, +] + +[package.optional-dependencies] +cli = [ + { name = "python-dotenv" }, + { name = "typer" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "more-itertools" +version = "10.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, +] + +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, + { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, + { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, + { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, +] + +[[package]] +name = "openapi-core" +version = "0.19.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "isodate" }, + { name = "jsonschema" }, + { name = "jsonschema-path" }, + { name = "more-itertools" }, + { name = "openapi-schema-validator" }, + { name = "openapi-spec-validator" }, + { name = "parse" }, + { name = "typing-extensions" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/35/1acaa5f2fcc6e54eded34a2ec74b479439c4e469fc4e8d0e803fda0234db/openapi_core-0.19.5.tar.gz", hash = "sha256:421e753da56c391704454e66afe4803a290108590ac8fa6f4a4487f4ec11f2d3", size = 103264, upload-time = "2025-03-20T20:17:28.193Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/6f/83ead0e2e30a90445ee4fc0135f43741aebc30cca5b43f20968b603e30b6/openapi_core-0.19.5-py3-none-any.whl", hash = "sha256:ef7210e83a59394f46ce282639d8d26ad6fc8094aa904c9c16eb1bac8908911f", size = 106595, upload-time = "2025-03-20T20:17:26.77Z" }, +] + +[[package]] +name = "openapi-pydantic" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892, upload-time = "2025-01-08T19:29:27.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" }, +] + +[[package]] +name = "openapi-schema-validator" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonschema" }, + { name = "jsonschema-specifications" }, + { name = "rfc3339-validator" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/f3/5507ad3325169347cd8ced61c232ff3df70e2b250c49f0fe140edb4973c6/openapi_schema_validator-0.6.3.tar.gz", hash = "sha256:f37bace4fc2a5d96692f4f8b31dc0f8d7400fd04f3a937798eaf880d425de6ee", size = 11550, upload-time = "2025-01-10T18:08:22.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/c6/ad0fba32775ae749016829dace42ed80f4407b171da41313d1a3a5f102e4/openapi_schema_validator-0.6.3-py3-none-any.whl", hash = "sha256:f3b9870f4e556b5a62a1c39da72a6b4b16f3ad9c73dc80084b1b11e74ba148a3", size = 8755, upload-time = "2025-01-10T18:08:19.758Z" }, +] + +[[package]] +name = "openapi-spec-validator" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonschema" }, + { name = "jsonschema-path" }, + { name = "lazy-object-proxy" }, + { name = "openapi-schema-validator" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/af/fe2d7618d6eae6fb3a82766a44ed87cd8d6d82b4564ed1c7cfb0f6378e91/openapi_spec_validator-0.7.2.tar.gz", hash = "sha256:cc029309b5c5dbc7859df0372d55e9d1ff43e96d678b9ba087f7c56fc586f734", size = 36855, upload-time = "2025-06-07T14:48:56.299Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/dd/b3fd642260cb17532f66cc1e8250f3507d1e580483e209dc1e9d13bd980d/openapi_spec_validator-0.7.2-py3-none-any.whl", hash = "sha256:4bbdc0894ec85f1d1bea1d6d9c8b2c3c8d7ccaa13577ef40da9c006c9fd0eb60", size = 39713, upload-time = "2025-06-07T14:48:54.077Z" }, +] + +[[package]] +name = "opencv-python" +version = "4.12.0.88" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/71/25c98e634b6bdeca4727c7f6d6927b056080668c5008ad3c8fc9e7f8f6ec/opencv-python-4.12.0.88.tar.gz", hash = "sha256:8b738389cede219405f6f3880b851efa3415ccd674752219377353f017d2994d", size = 95373294, upload-time = "2025-07-07T09:20:52.389Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/68/3da40142e7c21e9b1d4e7ddd6c58738feb013203e6e4b803d62cdd9eb96b/opencv_python-4.12.0.88-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:f9a1f08883257b95a5764bf517a32d75aec325319c8ed0f89739a57fae9e92a5", size = 37877727, upload-time = "2025-07-07T09:13:31.47Z" }, + { url = "https://files.pythonhosted.org/packages/33/7c/042abe49f58d6ee7e1028eefc3334d98ca69b030e3b567fe245a2b28ea6f/opencv_python-4.12.0.88-cp37-abi3-macosx_13_0_x86_64.whl", hash = "sha256:812eb116ad2b4de43ee116fcd8991c3a687f099ada0b04e68f64899c09448e81", size = 57326471, upload-time = "2025-07-07T09:13:41.26Z" }, + { url = "https://files.pythonhosted.org/packages/62/3a/440bd64736cf8116f01f3b7f9f2e111afb2e02beb2ccc08a6458114a6b5d/opencv_python-4.12.0.88-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:51fd981c7df6af3e8f70b1556696b05224c4e6b6777bdd2a46b3d4fb09de1a92", size = 45887139, upload-time = "2025-07-07T09:13:50.761Z" }, + { url = "https://files.pythonhosted.org/packages/68/1f/795e7f4aa2eacc59afa4fb61a2e35e510d06414dd5a802b51a012d691b37/opencv_python-4.12.0.88-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:092c16da4c5a163a818f120c22c5e4a2f96e0db4f24e659c701f1fe629a690f9", size = 67041680, upload-time = "2025-07-07T09:14:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/02/96/213fea371d3cb2f1d537612a105792aa0a6659fb2665b22cad709a75bd94/opencv_python-4.12.0.88-cp37-abi3-win32.whl", hash = "sha256:ff554d3f725b39878ac6a2e1fa232ec509c36130927afc18a1719ebf4fbf4357", size = 30284131, upload-time = "2025-07-07T09:14:08.819Z" }, + { url = "https://files.pythonhosted.org/packages/fa/80/eb88edc2e2b11cd2dd2e56f1c80b5784d11d6e6b7f04a1145df64df40065/opencv_python-4.12.0.88-cp37-abi3-win_amd64.whl", hash = "sha256:d98edb20aa932fd8ebd276a72627dad9dc097695b3d435a4257557bbb49a79d2", size = 39000307, upload-time = "2025-07-07T09:14:16.641Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "parse" +version = "1.20.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/78/d9b09ba24bb36ef8b83b71be547e118d46214735b6dfb39e4bfde0e9b9dd/parse-1.20.2.tar.gz", hash = "sha256:b41d604d16503c79d81af5165155c0b20f6c8d6c559efa66b4b695c3e5a0a0ce", size = 29391, upload-time = "2024-06-11T04:41:57.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/31/ba45bf0b2aa7898d81cbbfac0e88c267befb59ad91a19e36e1bc5578ddb1/parse-1.20.2-py2.py3-none-any.whl", hash = "sha256:967095588cb802add9177d0c0b6133b5ba33b1ea9007ca800e526f42a85af558", size = 20126, upload-time = "2024-06-11T04:41:55.057Z" }, +] + +[[package]] +name = "pathable" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/67/93/8f2c2075b180c12c1e9f6a09d1a985bc2036906b13dff1d8917e395f2048/pathable-0.4.4.tar.gz", hash = "sha256:6905a3cd17804edfac7875b5f6c9142a218c7caef78693c2dbbbfbac186d88b2", size = 8124, upload-time = "2025-01-10T18:43:13.247Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/eb/b6260b31b1a96386c0a880edebe26f89669098acea8e0318bff6adb378fd/pathable-0.4.4-py3-none-any.whl", hash = "sha256:5ae9e94793b6ef5a4cbe0a7ce9dbbefc1eec38df253763fd0aeeacf2762dbbc2", size = 9592, upload-time = "2025-01-10T18:43:11.88Z" }, +] + +[[package]] +name = "pathvalidate" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/2a/52a8da6fe965dea6192eb716b357558e103aea0a1e9a8352ad575a8406ca/pathvalidate-3.3.1.tar.gz", hash = "sha256:b18c07212bfead624345bb8e1d6141cdcf15a39736994ea0b94035ad2b1ba177", size = 63262, upload-time = "2025-06-15T09:07:20.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/70/875f4a23bfc4731703a5835487d0d2fb999031bd415e7d17c0ae615c18b7/pathvalidate-3.3.1-py3-none-any.whl", hash = "sha256:5263baab691f8e1af96092fa5137ee17df5bdfbd6cff1fcac4d6ef4bc2e1735f", size = 24305, upload-time = "2025-06-15T09:07:19.117Z" }, +] + +[[package]] +name = "pillow" +version = "12.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/08/26e68b6b5da219c2a2cb7b563af008b53bb8e6b6fcb3fa40715fcdb2523a/pillow-12.0.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:3adfb466bbc544b926d50fe8f4a4e6abd8c6bffd28a26177594e6e9b2b76572b", size = 5289809, upload-time = "2025-10-15T18:21:27.791Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/4e58fb097fb74c7b4758a680aacd558810a417d1edaa7000142976ef9d2f/pillow-12.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1ac11e8ea4f611c3c0147424eae514028b5e9077dd99ab91e1bd7bc33ff145e1", size = 4650606, upload-time = "2025-10-15T18:21:29.823Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e0/1fa492aa9f77b3bc6d471c468e62bfea1823056bf7e5e4f1914d7ab2565e/pillow-12.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d49e2314c373f4c2b39446fb1a45ed333c850e09d0c59ac79b72eb3b95397363", size = 6221023, upload-time = "2025-10-15T18:21:31.415Z" }, + { url = "https://files.pythonhosted.org/packages/c1/09/4de7cd03e33734ccd0c876f0251401f1314e819cbfd89a0fcb6e77927cc6/pillow-12.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c7b2a63fd6d5246349f3d3f37b14430d73ee7e8173154461785e43036ffa96ca", size = 8024937, upload-time = "2025-10-15T18:21:33.453Z" }, + { url = "https://files.pythonhosted.org/packages/2e/69/0688e7c1390666592876d9d474f5e135abb4acb39dcb583c4dc5490f1aff/pillow-12.0.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d64317d2587c70324b79861babb9c09f71fbb780bad212018874b2c013d8600e", size = 6334139, upload-time = "2025-10-15T18:21:35.395Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1c/880921e98f525b9b44ce747ad1ea8f73fd7e992bafe3ca5e5644bf433dea/pillow-12.0.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d77153e14b709fd8b8af6f66a3afbb9ed6e9fc5ccf0b6b7e1ced7b036a228782", size = 7026074, upload-time = "2025-10-15T18:21:37.219Z" }, + { url = "https://files.pythonhosted.org/packages/28/03/96f718331b19b355610ef4ebdbbde3557c726513030665071fd025745671/pillow-12.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:32ed80ea8a90ee3e6fa08c21e2e091bba6eda8eccc83dbc34c95169507a91f10", size = 6448852, upload-time = "2025-10-15T18:21:39.168Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a0/6a193b3f0cc9437b122978d2c5cbce59510ccf9a5b48825096ed7472da2f/pillow-12.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c828a1ae702fc712978bda0320ba1b9893d99be0badf2647f693cc01cf0f04fa", size = 7117058, upload-time = "2025-10-15T18:21:40.997Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c4/043192375eaa4463254e8e61f0e2ec9a846b983929a8d0a7122e0a6d6fff/pillow-12.0.0-cp310-cp310-win32.whl", hash = "sha256:bd87e140e45399c818fac4247880b9ce719e4783d767e030a883a970be632275", size = 6295431, upload-time = "2025-10-15T18:21:42.518Z" }, + { url = "https://files.pythonhosted.org/packages/92/c6/c2f2fc7e56301c21827e689bb8b0b465f1b52878b57471a070678c0c33cd/pillow-12.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:455247ac8a4cfb7b9bc45b7e432d10421aea9fc2e74d285ba4072688a74c2e9d", size = 7000412, upload-time = "2025-10-15T18:21:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d2/5f675067ba82da7a1c238a73b32e3fd78d67f9d9f80fbadd33a40b9c0481/pillow-12.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:6ace95230bfb7cd79ef66caa064bbe2f2a1e63d93471c3a2e1f1348d9f22d6b7", size = 2435903, upload-time = "2025-10-15T18:21:46.29Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5a/a2f6773b64edb921a756eb0729068acad9fc5208a53f4a349396e9436721/pillow-12.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc", size = 5289798, upload-time = "2025-10-15T18:21:47.763Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/069b1f8a2e4b5a37493da6c5868531c3f77b85e716ad7a590ef87d58730d/pillow-12.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257", size = 4650589, upload-time = "2025-10-15T18:21:49.515Z" }, + { url = "https://files.pythonhosted.org/packages/61/e3/2c820d6e9a36432503ead175ae294f96861b07600a7156154a086ba7111a/pillow-12.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642", size = 6230472, upload-time = "2025-10-15T18:21:51.052Z" }, + { url = "https://files.pythonhosted.org/packages/4f/89/63427f51c64209c5e23d4d52071c8d0f21024d3a8a487737caaf614a5795/pillow-12.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3", size = 8033887, upload-time = "2025-10-15T18:21:52.604Z" }, + { url = "https://files.pythonhosted.org/packages/f6/1b/c9711318d4901093c15840f268ad649459cd81984c9ec9887756cca049a5/pillow-12.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c", size = 6343964, upload-time = "2025-10-15T18:21:54.619Z" }, + { url = "https://files.pythonhosted.org/packages/41/1e/db9470f2d030b4995083044cd8738cdd1bf773106819f6d8ba12597d5352/pillow-12.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227", size = 7034756, upload-time = "2025-10-15T18:21:56.151Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b0/6177a8bdd5ee4ed87cba2de5a3cc1db55ffbbec6176784ce5bb75aa96798/pillow-12.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b", size = 6458075, upload-time = "2025-10-15T18:21:57.759Z" }, + { url = "https://files.pythonhosted.org/packages/bc/5e/61537aa6fa977922c6a03253a0e727e6e4a72381a80d63ad8eec350684f2/pillow-12.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e", size = 7125955, upload-time = "2025-10-15T18:21:59.372Z" }, + { url = "https://files.pythonhosted.org/packages/1f/3d/d5033539344ee3cbd9a4d69e12e63ca3a44a739eb2d4c8da350a3d38edd7/pillow-12.0.0-cp311-cp311-win32.whl", hash = "sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739", size = 6298440, upload-time = "2025-10-15T18:22:00.982Z" }, + { url = "https://files.pythonhosted.org/packages/4d/42/aaca386de5cc8bd8a0254516957c1f265e3521c91515b16e286c662854c4/pillow-12.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e", size = 6999256, upload-time = "2025-10-15T18:22:02.617Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f1/9197c9c2d5708b785f631a6dfbfa8eb3fb9672837cb92ae9af812c13b4ed/pillow-12.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d", size = 2436025, upload-time = "2025-10-15T18:22:04.598Z" }, + { url = "https://files.pythonhosted.org/packages/2c/90/4fcce2c22caf044e660a198d740e7fbc14395619e3cb1abad12192c0826c/pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371", size = 5249377, upload-time = "2025-10-15T18:22:05.993Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/ed960067543d080691d47d6938ebccbf3976a931c9567ab2fbfab983a5dd/pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082", size = 4650343, upload-time = "2025-10-15T18:22:07.718Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a1/f81fdeddcb99c044bf7d6faa47e12850f13cee0849537a7d27eeab5534d4/pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f", size = 6232981, upload-time = "2025-10-15T18:22:09.287Z" }, + { url = "https://files.pythonhosted.org/packages/88/e1/9098d3ce341a8750b55b0e00c03f1630d6178f38ac191c81c97a3b047b44/pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d", size = 8041399, upload-time = "2025-10-15T18:22:10.872Z" }, + { url = "https://files.pythonhosted.org/packages/a7/62/a22e8d3b602ae8cc01446d0c57a54e982737f44b6f2e1e019a925143771d/pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953", size = 6347740, upload-time = "2025-10-15T18:22:12.769Z" }, + { url = "https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8", size = 7040201, upload-time = "2025-10-15T18:22:14.813Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4d/435c8ac688c54d11755aedfdd9f29c9eeddf68d150fe42d1d3dbd2365149/pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79", size = 6462334, upload-time = "2025-10-15T18:22:16.375Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f2/ad34167a8059a59b8ad10bc5c72d4d9b35acc6b7c0877af8ac885b5f2044/pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba", size = 7134162, upload-time = "2025-10-15T18:22:17.996Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/a7391df6adacf0a5c2cf6ac1cf1fcc1369e7d439d28f637a847f8803beb3/pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0", size = 6298769, upload-time = "2025-10-15T18:22:19.923Z" }, + { url = "https://files.pythonhosted.org/packages/a2/0b/d87733741526541c909bbf159e338dcace4f982daac6e5a8d6be225ca32d/pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a", size = 7001107, upload-time = "2025-10-15T18:22:21.644Z" }, + { url = "https://files.pythonhosted.org/packages/bc/96/aaa61ce33cc98421fb6088af2a03be4157b1e7e0e87087c888e2370a7f45/pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad", size = 2436012, upload-time = "2025-10-15T18:22:23.621Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/de993bb2d21b33a98d031ecf6a978e4b61da207bef02f7b43093774c480d/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", size = 4045493, upload-time = "2025-10-15T18:22:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b6/bc8d0c4c9f6f111a783d045310945deb769b806d7574764234ffd50bc5ea/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", size = 4120461, upload-time = "2025-10-15T18:22:27.286Z" }, + { url = "https://files.pythonhosted.org/packages/5d/57/d60d343709366a353dc56adb4ee1e7d8a2cc34e3fbc22905f4167cfec119/pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", size = 3576912, upload-time = "2025-10-15T18:22:28.751Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a4/a0a31467e3f83b94d37568294b01d22b43ae3c5d85f2811769b9c66389dd/pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", size = 5249132, upload-time = "2025-10-15T18:22:30.641Z" }, + { url = "https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", size = 4650099, upload-time = "2025-10-15T18:22:32.73Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bd/69ed99fd46a8dba7c1887156d3572fe4484e3f031405fcc5a92e31c04035/pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", size = 6230808, upload-time = "2025-10-15T18:22:34.337Z" }, + { url = "https://files.pythonhosted.org/packages/ea/94/8fad659bcdbf86ed70099cb60ae40be6acca434bbc8c4c0d4ef356d7e0de/pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", size = 8037804, upload-time = "2025-10-15T18:22:36.402Z" }, + { url = "https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", size = 6345553, upload-time = "2025-10-15T18:22:38.066Z" }, + { url = "https://files.pythonhosted.org/packages/38/57/755dbd06530a27a5ed74f8cb0a7a44a21722ebf318edbe67ddbd7fb28f88/pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", size = 7037729, upload-time = "2025-10-15T18:22:39.769Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/7e94f4c41d238615674d06ed677c14883103dce1c52e4af16f000338cfd7/pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", size = 6459789, upload-time = "2025-10-15T18:22:41.437Z" }, + { url = "https://files.pythonhosted.org/packages/9c/14/4448bb0b5e0f22dd865290536d20ec8a23b64e2d04280b89139f09a36bb6/pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", size = 7130917, upload-time = "2025-10-15T18:22:43.152Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ca/16c6926cc1c015845745d5c16c9358e24282f1e588237a4c36d2b30f182f/pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", size = 6302391, upload-time = "2025-10-15T18:22:44.753Z" }, + { url = "https://files.pythonhosted.org/packages/6d/2a/dd43dcfd6dae9b6a49ee28a8eedb98c7d5ff2de94a5d834565164667b97b/pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", size = 7007477, upload-time = "2025-10-15T18:22:46.838Z" }, + { url = "https://files.pythonhosted.org/packages/77/f0/72ea067f4b5ae5ead653053212af05ce3705807906ba3f3e8f58ddf617e6/pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", size = 2435918, upload-time = "2025-10-15T18:22:48.399Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5e/9046b423735c21f0487ea6cb5b10f89ea8f8dfbe32576fe052b5ba9d4e5b/pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", size = 5251406, upload-time = "2025-10-15T18:22:49.905Z" }, + { url = "https://files.pythonhosted.org/packages/12/66/982ceebcdb13c97270ef7a56c3969635b4ee7cd45227fa707c94719229c5/pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", size = 4653218, upload-time = "2025-10-15T18:22:51.587Z" }, + { url = "https://files.pythonhosted.org/packages/16/b3/81e625524688c31859450119bf12674619429cab3119eec0e30a7a1029cb/pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", size = 6266564, upload-time = "2025-10-15T18:22:53.215Z" }, + { url = "https://files.pythonhosted.org/packages/98/59/dfb38f2a41240d2408096e1a76c671d0a105a4a8471b1871c6902719450c/pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", size = 8069260, upload-time = "2025-10-15T18:22:54.933Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3d/378dbea5cd1874b94c312425ca77b0f47776c78e0df2df751b820c8c1d6c/pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", size = 6379248, upload-time = "2025-10-15T18:22:56.605Z" }, + { url = "https://files.pythonhosted.org/packages/84/b0/d525ef47d71590f1621510327acec75ae58c721dc071b17d8d652ca494d8/pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", size = 7066043, upload-time = "2025-10-15T18:22:58.53Z" }, + { url = "https://files.pythonhosted.org/packages/61/2c/aced60e9cf9d0cde341d54bf7932c9ffc33ddb4a1595798b3a5150c7ec4e/pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", size = 6490915, upload-time = "2025-10-15T18:23:00.582Z" }, + { url = "https://files.pythonhosted.org/packages/ef/26/69dcb9b91f4e59f8f34b2332a4a0a951b44f547c4ed39d3e4dcfcff48f89/pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", size = 7157998, upload-time = "2025-10-15T18:23:02.627Z" }, + { url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201, upload-time = "2025-10-15T18:23:04.709Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165, upload-time = "2025-10-15T18:23:06.46Z" }, + { url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834, upload-time = "2025-10-15T18:23:08.194Z" }, + { url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531, upload-time = "2025-10-15T18:23:10.121Z" }, + { url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554, upload-time = "2025-10-15T18:23:12.14Z" }, + { url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812, upload-time = "2025-10-15T18:23:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689, upload-time = "2025-10-15T18:23:15.562Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186, upload-time = "2025-10-15T18:23:17.379Z" }, + { url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308, upload-time = "2025-10-15T18:23:18.971Z" }, + { url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222, upload-time = "2025-10-15T18:23:20.909Z" }, + { url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657, upload-time = "2025-10-15T18:23:23.077Z" }, + { url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482, upload-time = "2025-10-15T18:23:25.005Z" }, + { url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416, upload-time = "2025-10-15T18:23:27.009Z" }, + { url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584, upload-time = "2025-10-15T18:23:29.752Z" }, + { url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621, upload-time = "2025-10-15T18:23:32.06Z" }, + { url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916, upload-time = "2025-10-15T18:23:34.71Z" }, + { url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836, upload-time = "2025-10-15T18:23:36.967Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092, upload-time = "2025-10-15T18:23:38.573Z" }, + { url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158, upload-time = "2025-10-15T18:23:40.238Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882, upload-time = "2025-10-15T18:23:42.434Z" }, + { url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001, upload-time = "2025-10-15T18:23:44.29Z" }, + { url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146, upload-time = "2025-10-15T18:23:46.065Z" }, + { url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344, upload-time = "2025-10-15T18:23:47.898Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864, upload-time = "2025-10-15T18:23:49.607Z" }, + { url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911, upload-time = "2025-10-15T18:23:51.351Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045, upload-time = "2025-10-15T18:23:53.177Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282, upload-time = "2025-10-15T18:23:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b3/582327e6c9f86d037b63beebe981425d6811104cb443e8193824ef1a2f27/pillow-12.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8", size = 5215068, upload-time = "2025-10-15T18:23:59.594Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d6/67748211d119f3b6540baf90f92fae73ae51d5217b171b0e8b5f7e5d558f/pillow-12.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a", size = 4614994, upload-time = "2025-10-15T18:24:01.669Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e1/f8281e5d844c41872b273b9f2c34a4bf64ca08905668c8ae730eedc7c9fa/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197", size = 5246639, upload-time = "2025-10-15T18:24:03.403Z" }, + { url = "https://files.pythonhosted.org/packages/94/5a/0d8ab8ffe8a102ff5df60d0de5af309015163bf710c7bb3e8311dd3b3ad0/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c", size = 6986839, upload-time = "2025-10-15T18:24:05.344Z" }, + { url = "https://files.pythonhosted.org/packages/20/2e/3434380e8110b76cd9eb00a363c484b050f949b4bbe84ba770bb8508a02c/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e", size = 5313505, upload-time = "2025-10-15T18:24:07.137Z" }, + { url = "https://files.pythonhosted.org/packages/57/ca/5a9d38900d9d74785141d6580950fe705de68af735ff6e727cb911b64740/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76", size = 5963654, upload-time = "2025-10-15T18:24:09.579Z" }, + { url = "https://files.pythonhosted.org/packages/95/7e/f896623c3c635a90537ac093c6a618ebe1a90d87206e42309cb5d98a1b9e/pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5", size = 6997850, upload-time = "2025-10-15T18:24:11.495Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "py-key-value-aio" +version = "0.2.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beartype" }, + { name = "py-key-value-shared" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/35/65310a4818acec0f87a46e5565e341c5a96fc062a9a03495ad28828ff4d7/py_key_value_aio-0.2.8.tar.gz", hash = "sha256:c0cfbb0bd4e962a3fa1a9fa6db9ba9df812899bd9312fa6368aaea7b26008b36", size = 32853, upload-time = "2025-10-24T13:31:04.688Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/5a/e56747d87a97ad2aff0f3700d77f186f0704c90c2da03bfed9e113dae284/py_key_value_aio-0.2.8-py3-none-any.whl", hash = "sha256:561565547ce8162128fd2bd0b9d70ce04a5f4586da8500cce79a54dfac78c46a", size = 69200, upload-time = "2025-10-24T13:31:03.81Z" }, +] + +[package.optional-dependencies] +disk = [ + { name = "diskcache" }, + { name = "pathvalidate" }, +] +keyring = [ + { name = "keyring" }, +] +memory = [ + { name = "cachetools" }, +] + +[[package]] +name = "py-key-value-shared" +version = "0.2.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beartype" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/79/05a1f9280cfa0709479319cbfd2b1c5beb23d5034624f548c83fb65b0b61/py_key_value_shared-0.2.8.tar.gz", hash = "sha256:703b4d3c61af124f0d528ba85995c3c8d78f8bd3d2b217377bd3278598070cc1", size = 8216, upload-time = "2025-10-24T13:31:03.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/7a/1726ceaa3343874f322dd83c9ec376ad81f533df8422b8b1e1233a59f8ce/py_key_value_shared-0.2.8-py3-none-any.whl", hash = "sha256:aff1bbfd46d065b2d67897d298642e80e5349eae588c6d11b48452b46b8d46ba", size = 14586, upload-time = "2025-10-24T13:31:02.838Z" }, +] + +[[package]] +name = "pycparser" +version = "2.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.3" +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/f3/1e/4f0a3233767010308f2fd6bd0814597e3f63f1dc98304a9112b8759df4ff/pydantic-2.12.3.tar.gz", hash = "sha256:1da1c82b0fc140bb0103bc1441ffe062154c8d38491189751ee00fd8ca65ce74", size = 819383, upload-time = "2025-10-17T15:04:21.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/6b/83661fa77dcefa195ad5f8cd9af3d1a7450fd57cc883ad04d65446ac2029/pydantic-2.12.3-py3-none-any.whl", hash = "sha256:6986454a854bc3bc6e5443e1369e06a3a456af9d339eda45510f517d9ea5c6bf", size = 462431, upload-time = "2025-10-17T15:04:19.346Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/18/d0944e8eaaa3efd0a91b0f1fc537d3be55ad35091b6a87638211ba691964/pydantic_core-2.41.4.tar.gz", hash = "sha256:70e47929a9d4a1905a67e4b687d5946026390568a8e952b92824118063cee4d5", size = 457557, upload-time = "2025-10-14T10:23:47.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/3d/9b8ca77b0f76fcdbf8bc6b72474e264283f461284ca84ac3fde570c6c49a/pydantic_core-2.41.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2442d9a4d38f3411f22eb9dd0912b7cbf4b7d5b6c92c4173b75d3e1ccd84e36e", size = 2111197, upload-time = "2025-10-14T10:19:43.303Z" }, + { url = "https://files.pythonhosted.org/packages/59/92/b7b0fe6ed4781642232755cb7e56a86e2041e1292f16d9ae410a0ccee5ac/pydantic_core-2.41.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:30a9876226dda131a741afeab2702e2d127209bde3c65a2b8133f428bc5d006b", size = 1917909, upload-time = "2025-10-14T10:19:45.194Z" }, + { url = "https://files.pythonhosted.org/packages/52/8c/3eb872009274ffa4fb6a9585114e161aa1a0915af2896e2d441642929fe4/pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d55bbac04711e2980645af68b97d445cdbcce70e5216de444a6c4b6943ebcccd", size = 1969905, upload-time = "2025-10-14T10:19:46.567Z" }, + { url = "https://files.pythonhosted.org/packages/f4/21/35adf4a753bcfaea22d925214a0c5b880792e3244731b3f3e6fec0d124f7/pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e1d778fb7849a42d0ee5927ab0f7453bf9f85eef8887a546ec87db5ddb178945", size = 2051938, upload-time = "2025-10-14T10:19:48.237Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d0/cdf7d126825e36d6e3f1eccf257da8954452934ede275a8f390eac775e89/pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b65077a4693a98b90ec5ad8f203ad65802a1b9b6d4a7e48066925a7e1606706", size = 2250710, upload-time = "2025-10-14T10:19:49.619Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1c/af1e6fd5ea596327308f9c8d1654e1285cc3d8de0d584a3c9d7705bf8a7c/pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62637c769dee16eddb7686bf421be48dfc2fae93832c25e25bc7242e698361ba", size = 2367445, upload-time = "2025-10-14T10:19:51.269Z" }, + { url = "https://files.pythonhosted.org/packages/d3/81/8cece29a6ef1b3a92f956ea6da6250d5b2d2e7e4d513dd3b4f0c7a83dfea/pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2dfe3aa529c8f501babf6e502936b9e8d4698502b2cfab41e17a028d91b1ac7b", size = 2072875, upload-time = "2025-10-14T10:19:52.671Z" }, + { url = "https://files.pythonhosted.org/packages/e3/37/a6a579f5fc2cd4d5521284a0ab6a426cc6463a7b3897aeb95b12f1ba607b/pydantic_core-2.41.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ca2322da745bf2eeb581fc9ea3bbb31147702163ccbcbf12a3bb630e4bf05e1d", size = 2191329, upload-time = "2025-10-14T10:19:54.214Z" }, + { url = "https://files.pythonhosted.org/packages/ae/03/505020dc5c54ec75ecba9f41119fd1e48f9e41e4629942494c4a8734ded1/pydantic_core-2.41.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e8cd3577c796be7231dcf80badcf2e0835a46665eaafd8ace124d886bab4d700", size = 2151658, upload-time = "2025-10-14T10:19:55.843Z" }, + { url = "https://files.pythonhosted.org/packages/cb/5d/2c0d09fb53aa03bbd2a214d89ebfa6304be7df9ed86ee3dc7770257f41ee/pydantic_core-2.41.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:1cae8851e174c83633f0833e90636832857297900133705ee158cf79d40f03e6", size = 2316777, upload-time = "2025-10-14T10:19:57.607Z" }, + { url = "https://files.pythonhosted.org/packages/ea/4b/c2c9c8f5e1f9c864b57d08539d9d3db160e00491c9f5ee90e1bfd905e644/pydantic_core-2.41.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a26d950449aae348afe1ac8be5525a00ae4235309b729ad4d3399623125b43c9", size = 2320705, upload-time = "2025-10-14T10:19:59.016Z" }, + { url = "https://files.pythonhosted.org/packages/28/c3/a74c1c37f49c0a02c89c7340fafc0ba816b29bd495d1a31ce1bdeacc6085/pydantic_core-2.41.4-cp310-cp310-win32.whl", hash = "sha256:0cf2a1f599efe57fa0051312774280ee0f650e11152325e41dfd3018ef2c1b57", size = 1975464, upload-time = "2025-10-14T10:20:00.581Z" }, + { url = "https://files.pythonhosted.org/packages/d6/23/5dd5c1324ba80303368f7569e2e2e1a721c7d9eb16acb7eb7b7f85cb1be2/pydantic_core-2.41.4-cp310-cp310-win_amd64.whl", hash = "sha256:a8c2e340d7e454dc3340d3d2e8f23558ebe78c98aa8f68851b04dcb7bc37abdc", size = 2024497, upload-time = "2025-10-14T10:20:03.018Z" }, + { url = "https://files.pythonhosted.org/packages/62/4c/f6cbfa1e8efacd00b846764e8484fe173d25b8dab881e277a619177f3384/pydantic_core-2.41.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:28ff11666443a1a8cf2a044d6a545ebffa8382b5f7973f22c36109205e65dc80", size = 2109062, upload-time = "2025-10-14T10:20:04.486Z" }, + { url = "https://files.pythonhosted.org/packages/21/f8/40b72d3868896bfcd410e1bd7e516e762d326201c48e5b4a06446f6cf9e8/pydantic_core-2.41.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:61760c3925d4633290292bad462e0f737b840508b4f722247d8729684f6539ae", size = 1916301, upload-time = "2025-10-14T10:20:06.857Z" }, + { url = "https://files.pythonhosted.org/packages/94/4d/d203dce8bee7faeca791671c88519969d98d3b4e8f225da5b96dad226fc8/pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eae547b7315d055b0de2ec3965643b0ab82ad0106a7ffd29615ee9f266a02827", size = 1968728, upload-time = "2025-10-14T10:20:08.353Z" }, + { url = "https://files.pythonhosted.org/packages/65/f5/6a66187775df87c24d526985b3a5d78d861580ca466fbd9d4d0e792fcf6c/pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ef9ee5471edd58d1fcce1c80ffc8783a650e3e3a193fe90d52e43bb4d87bff1f", size = 2050238, upload-time = "2025-10-14T10:20:09.766Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b9/78336345de97298cf53236b2f271912ce11f32c1e59de25a374ce12f9cce/pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:15dd504af121caaf2c95cb90c0ebf71603c53de98305621b94da0f967e572def", size = 2249424, upload-time = "2025-10-14T10:20:11.732Z" }, + { url = "https://files.pythonhosted.org/packages/99/bb/a4584888b70ee594c3d374a71af5075a68654d6c780369df269118af7402/pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a926768ea49a8af4d36abd6a8968b8790f7f76dd7cbd5a4c180db2b4ac9a3a2", size = 2366047, upload-time = "2025-10-14T10:20:13.647Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8d/17fc5de9d6418e4d2ae8c675f905cdafdc59d3bf3bf9c946b7ab796a992a/pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6916b9b7d134bff5440098a4deb80e4cb623e68974a87883299de9124126c2a8", size = 2071163, upload-time = "2025-10-14T10:20:15.307Z" }, + { url = "https://files.pythonhosted.org/packages/54/e7/03d2c5c0b8ed37a4617430db68ec5e7dbba66358b629cd69e11b4d564367/pydantic_core-2.41.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5cf90535979089df02e6f17ffd076f07237efa55b7343d98760bde8743c4b265", size = 2190585, upload-time = "2025-10-14T10:20:17.3Z" }, + { url = "https://files.pythonhosted.org/packages/be/fc/15d1c9fe5ad9266a5897d9b932b7f53d7e5cfc800573917a2c5d6eea56ec/pydantic_core-2.41.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:7533c76fa647fade2d7ec75ac5cc079ab3f34879626dae5689b27790a6cf5a5c", size = 2150109, upload-time = "2025-10-14T10:20:19.143Z" }, + { url = "https://files.pythonhosted.org/packages/26/ef/e735dd008808226c83ba56972566138665b71477ad580fa5a21f0851df48/pydantic_core-2.41.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:37e516bca9264cbf29612539801ca3cd5d1be465f940417b002905e6ed79d38a", size = 2315078, upload-time = "2025-10-14T10:20:20.742Z" }, + { url = "https://files.pythonhosted.org/packages/90/00/806efdcf35ff2ac0f938362350cd9827b8afb116cc814b6b75cf23738c7c/pydantic_core-2.41.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0c19cb355224037c83642429b8ce261ae108e1c5fbf5c028bac63c77b0f8646e", size = 2318737, upload-time = "2025-10-14T10:20:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/41/7e/6ac90673fe6cb36621a2283552897838c020db343fa86e513d3f563b196f/pydantic_core-2.41.4-cp311-cp311-win32.whl", hash = "sha256:09c2a60e55b357284b5f31f5ab275ba9f7f70b7525e18a132ec1f9160b4f1f03", size = 1974160, upload-time = "2025-10-14T10:20:23.817Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9d/7c5e24ee585c1f8b6356e1d11d40ab807ffde44d2db3b7dfd6d20b09720e/pydantic_core-2.41.4-cp311-cp311-win_amd64.whl", hash = "sha256:711156b6afb5cb1cb7c14a2cc2c4a8b4c717b69046f13c6b332d8a0a8f41ca3e", size = 2021883, upload-time = "2025-10-14T10:20:25.48Z" }, + { url = "https://files.pythonhosted.org/packages/33/90/5c172357460fc28b2871eb4a0fb3843b136b429c6fa827e4b588877bf115/pydantic_core-2.41.4-cp311-cp311-win_arm64.whl", hash = "sha256:6cb9cf7e761f4f8a8589a45e49ed3c0d92d1d696a45a6feaee8c904b26efc2db", size = 1968026, upload-time = "2025-10-14T10:20:27.039Z" }, + { url = "https://files.pythonhosted.org/packages/e9/81/d3b3e95929c4369d30b2a66a91db63c8ed0a98381ae55a45da2cd1cc1288/pydantic_core-2.41.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ab06d77e053d660a6faaf04894446df7b0a7e7aba70c2797465a0a1af00fc887", size = 2099043, upload-time = "2025-10-14T10:20:28.561Z" }, + { url = "https://files.pythonhosted.org/packages/58/da/46fdac49e6717e3a94fc9201403e08d9d61aa7a770fab6190b8740749047/pydantic_core-2.41.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c53ff33e603a9c1179a9364b0a24694f183717b2e0da2b5ad43c316c956901b2", size = 1910699, upload-time = "2025-10-14T10:20:30.217Z" }, + { url = "https://files.pythonhosted.org/packages/1e/63/4d948f1b9dd8e991a5a98b77dd66c74641f5f2e5225fee37994b2e07d391/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:304c54176af2c143bd181d82e77c15c41cbacea8872a2225dd37e6544dce9999", size = 1952121, upload-time = "2025-10-14T10:20:32.246Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a7/e5fc60a6f781fc634ecaa9ecc3c20171d238794cef69ae0af79ac11b89d7/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:025ba34a4cf4fb32f917d5d188ab5e702223d3ba603be4d8aca2f82bede432a4", size = 2041590, upload-time = "2025-10-14T10:20:34.332Z" }, + { url = "https://files.pythonhosted.org/packages/70/69/dce747b1d21d59e85af433428978a1893c6f8a7068fa2bb4a927fba7a5ff/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9f5f30c402ed58f90c70e12eff65547d3ab74685ffe8283c719e6bead8ef53f", size = 2219869, upload-time = "2025-10-14T10:20:35.965Z" }, + { url = "https://files.pythonhosted.org/packages/83/6a/c070e30e295403bf29c4df1cb781317b6a9bac7cd07b8d3acc94d501a63c/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd96e5d15385d301733113bcaa324c8bcf111275b7675a9c6e88bfb19fc05e3b", size = 2345169, upload-time = "2025-10-14T10:20:37.627Z" }, + { url = "https://files.pythonhosted.org/packages/f0/83/06d001f8043c336baea7fd202a9ac7ad71f87e1c55d8112c50b745c40324/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98f348cbb44fae6e9653c1055db7e29de67ea6a9ca03a5fa2c2e11a47cff0e47", size = 2070165, upload-time = "2025-10-14T10:20:39.246Z" }, + { url = "https://files.pythonhosted.org/packages/14/0a/e567c2883588dd12bcbc110232d892cf385356f7c8a9910311ac997ab715/pydantic_core-2.41.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec22626a2d14620a83ca583c6f5a4080fa3155282718b6055c2ea48d3ef35970", size = 2189067, upload-time = "2025-10-14T10:20:41.015Z" }, + { url = "https://files.pythonhosted.org/packages/f4/1d/3d9fca34273ba03c9b1c5289f7618bc4bd09c3ad2289b5420481aa051a99/pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3a95d4590b1f1a43bf33ca6d647b990a88f4a3824a8c4572c708f0b45a5290ed", size = 2132997, upload-time = "2025-10-14T10:20:43.106Z" }, + { url = "https://files.pythonhosted.org/packages/52/70/d702ef7a6cd41a8afc61f3554922b3ed8d19dd54c3bd4bdbfe332e610827/pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:f9672ab4d398e1b602feadcffcdd3af44d5f5e6ddc15bc7d15d376d47e8e19f8", size = 2307187, upload-time = "2025-10-14T10:20:44.849Z" }, + { url = "https://files.pythonhosted.org/packages/68/4c/c06be6e27545d08b802127914156f38d10ca287a9e8489342793de8aae3c/pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:84d8854db5f55fead3b579f04bda9a36461dab0730c5d570e1526483e7bb8431", size = 2305204, upload-time = "2025-10-14T10:20:46.781Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e5/35ae4919bcd9f18603419e23c5eaf32750224a89d41a8df1a3704b69f77e/pydantic_core-2.41.4-cp312-cp312-win32.whl", hash = "sha256:9be1c01adb2ecc4e464392c36d17f97e9110fbbc906bcbe1c943b5b87a74aabd", size = 1972536, upload-time = "2025-10-14T10:20:48.39Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c2/49c5bb6d2a49eb2ee3647a93e3dae7080c6409a8a7558b075027644e879c/pydantic_core-2.41.4-cp312-cp312-win_amd64.whl", hash = "sha256:d682cf1d22bab22a5be08539dca3d1593488a99998f9f412137bc323179067ff", size = 2031132, upload-time = "2025-10-14T10:20:50.421Z" }, + { url = "https://files.pythonhosted.org/packages/06/23/936343dbcba6eec93f73e95eb346810fc732f71ba27967b287b66f7b7097/pydantic_core-2.41.4-cp312-cp312-win_arm64.whl", hash = "sha256:833eebfd75a26d17470b58768c1834dfc90141b7afc6eb0429c21fc5a21dcfb8", size = 1969483, upload-time = "2025-10-14T10:20:52.35Z" }, + { url = "https://files.pythonhosted.org/packages/13/d0/c20adabd181a029a970738dfe23710b52a31f1258f591874fcdec7359845/pydantic_core-2.41.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:85e050ad9e5f6fe1004eec65c914332e52f429bc0ae12d6fa2092407a462c746", size = 2105688, upload-time = "2025-10-14T10:20:54.448Z" }, + { url = "https://files.pythonhosted.org/packages/00/b6/0ce5c03cec5ae94cca220dfecddc453c077d71363b98a4bbdb3c0b22c783/pydantic_core-2.41.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7393f1d64792763a48924ba31d1e44c2cfbc05e3b1c2c9abb4ceeadd912cced", size = 1910807, upload-time = "2025-10-14T10:20:56.115Z" }, + { url = "https://files.pythonhosted.org/packages/68/3e/800d3d02c8beb0b5c069c870cbb83799d085debf43499c897bb4b4aaff0d/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94dab0940b0d1fb28bcab847adf887c66a27a40291eedf0b473be58761c9799a", size = 1956669, upload-time = "2025-10-14T10:20:57.874Z" }, + { url = "https://files.pythonhosted.org/packages/60/a4/24271cc71a17f64589be49ab8bd0751f6a0a03046c690df60989f2f95c2c/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:de7c42f897e689ee6f9e93c4bec72b99ae3b32a2ade1c7e4798e690ff5246e02", size = 2051629, upload-time = "2025-10-14T10:21:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/68/de/45af3ca2f175d91b96bfb62e1f2d2f1f9f3b14a734afe0bfeff079f78181/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:664b3199193262277b8b3cd1e754fb07f2c6023289c815a1e1e8fb415cb247b1", size = 2224049, upload-time = "2025-10-14T10:21:01.801Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/ae4e1ff84672bf869d0a77af24fd78387850e9497753c432875066b5d622/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d95b253b88f7d308b1c0b417c4624f44553ba4762816f94e6986819b9c273fb2", size = 2342409, upload-time = "2025-10-14T10:21:03.556Z" }, + { url = "https://files.pythonhosted.org/packages/18/62/273dd70b0026a085c7b74b000394e1ef95719ea579c76ea2f0cc8893736d/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1351f5bbdbbabc689727cb91649a00cb9ee7203e0a6e54e9f5ba9e22e384b84", size = 2069635, upload-time = "2025-10-14T10:21:05.385Z" }, + { url = "https://files.pythonhosted.org/packages/30/03/cf485fff699b4cdaea469bc481719d3e49f023241b4abb656f8d422189fc/pydantic_core-2.41.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1affa4798520b148d7182da0615d648e752de4ab1a9566b7471bc803d88a062d", size = 2194284, upload-time = "2025-10-14T10:21:07.122Z" }, + { url = "https://files.pythonhosted.org/packages/f9/7e/c8e713db32405dfd97211f2fc0a15d6bf8adb7640f3d18544c1f39526619/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7b74e18052fea4aa8dea2fb7dbc23d15439695da6cbe6cfc1b694af1115df09d", size = 2137566, upload-time = "2025-10-14T10:21:08.981Z" }, + { url = "https://files.pythonhosted.org/packages/04/f7/db71fd4cdccc8b75990f79ccafbbd66757e19f6d5ee724a6252414483fb4/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:285b643d75c0e30abda9dc1077395624f314a37e3c09ca402d4015ef5979f1a2", size = 2316809, upload-time = "2025-10-14T10:21:10.805Z" }, + { url = "https://files.pythonhosted.org/packages/76/63/a54973ddb945f1bca56742b48b144d85c9fc22f819ddeb9f861c249d5464/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:f52679ff4218d713b3b33f88c89ccbf3a5c2c12ba665fb80ccc4192b4608dbab", size = 2311119, upload-time = "2025-10-14T10:21:12.583Z" }, + { url = "https://files.pythonhosted.org/packages/f8/03/5d12891e93c19218af74843a27e32b94922195ded2386f7b55382f904d2f/pydantic_core-2.41.4-cp313-cp313-win32.whl", hash = "sha256:ecde6dedd6fff127c273c76821bb754d793be1024bc33314a120f83a3c69460c", size = 1981398, upload-time = "2025-10-14T10:21:14.584Z" }, + { url = "https://files.pythonhosted.org/packages/be/d8/fd0de71f39db91135b7a26996160de71c073d8635edfce8b3c3681be0d6d/pydantic_core-2.41.4-cp313-cp313-win_amd64.whl", hash = "sha256:d081a1f3800f05409ed868ebb2d74ac39dd0c1ff6c035b5162356d76030736d4", size = 2030735, upload-time = "2025-10-14T10:21:16.432Z" }, + { url = "https://files.pythonhosted.org/packages/72/86/c99921c1cf6650023c08bfab6fe2d7057a5142628ef7ccfa9921f2dda1d5/pydantic_core-2.41.4-cp313-cp313-win_arm64.whl", hash = "sha256:f8e49c9c364a7edcbe2a310f12733aad95b022495ef2a8d653f645e5d20c1564", size = 1973209, upload-time = "2025-10-14T10:21:18.213Z" }, + { url = "https://files.pythonhosted.org/packages/36/0d/b5706cacb70a8414396efdda3d72ae0542e050b591119e458e2490baf035/pydantic_core-2.41.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ed97fd56a561f5eb5706cebe94f1ad7c13b84d98312a05546f2ad036bafe87f4", size = 1877324, upload-time = "2025-10-14T10:21:20.363Z" }, + { url = "https://files.pythonhosted.org/packages/de/2d/cba1fa02cfdea72dfb3a9babb067c83b9dff0bbcb198368e000a6b756ea7/pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a870c307bf1ee91fc58a9a61338ff780d01bfae45922624816878dce784095d2", size = 1884515, upload-time = "2025-10-14T10:21:22.339Z" }, + { url = "https://files.pythonhosted.org/packages/07/ea/3df927c4384ed9b503c9cc2d076cf983b4f2adb0c754578dfb1245c51e46/pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d25e97bc1f5f8f7985bdc2335ef9e73843bb561eb1fa6831fdfc295c1c2061cf", size = 2042819, upload-time = "2025-10-14T10:21:26.683Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ee/df8e871f07074250270a3b1b82aad4cd0026b588acd5d7d3eb2fcb1471a3/pydantic_core-2.41.4-cp313-cp313t-win_amd64.whl", hash = "sha256:d405d14bea042f166512add3091c1af40437c2e7f86988f3915fabd27b1e9cd2", size = 1995866, upload-time = "2025-10-14T10:21:28.951Z" }, + { url = "https://files.pythonhosted.org/packages/fc/de/b20f4ab954d6d399499c33ec4fafc46d9551e11dc1858fb7f5dca0748ceb/pydantic_core-2.41.4-cp313-cp313t-win_arm64.whl", hash = "sha256:19f3684868309db5263a11bace3c45d93f6f24afa2ffe75a647583df22a2ff89", size = 1970034, upload-time = "2025-10-14T10:21:30.869Z" }, + { url = "https://files.pythonhosted.org/packages/54/28/d3325da57d413b9819365546eb9a6e8b7cbd9373d9380efd5f74326143e6/pydantic_core-2.41.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e9205d97ed08a82ebb9a307e92914bb30e18cdf6f6b12ca4bedadb1588a0bfe1", size = 2102022, upload-time = "2025-10-14T10:21:32.809Z" }, + { url = "https://files.pythonhosted.org/packages/9e/24/b58a1bc0d834bf1acc4361e61233ee217169a42efbdc15a60296e13ce438/pydantic_core-2.41.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:82df1f432b37d832709fbcc0e24394bba04a01b6ecf1ee87578145c19cde12ac", size = 1905495, upload-time = "2025-10-14T10:21:34.812Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a4/71f759cc41b7043e8ecdaab81b985a9b6cad7cec077e0b92cff8b71ecf6b/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3b4cc4539e055cfa39a3763c939f9d409eb40e85813257dcd761985a108554", size = 1956131, upload-time = "2025-10-14T10:21:36.924Z" }, + { url = "https://files.pythonhosted.org/packages/b0/64/1e79ac7aa51f1eec7c4cda8cbe456d5d09f05fdd68b32776d72168d54275/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b1eb1754fce47c63d2ff57fdb88c351a6c0150995890088b33767a10218eaa4e", size = 2052236, upload-time = "2025-10-14T10:21:38.927Z" }, + { url = "https://files.pythonhosted.org/packages/e9/e3/a3ffc363bd4287b80f1d43dc1c28ba64831f8dfc237d6fec8f2661138d48/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6ab5ab30ef325b443f379ddb575a34969c333004fca5a1daa0133a6ffaad616", size = 2223573, upload-time = "2025-10-14T10:21:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/28/27/78814089b4d2e684a9088ede3790763c64693c3d1408ddc0a248bc789126/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:31a41030b1d9ca497634092b46481b937ff9397a86f9f51bd41c4767b6fc04af", size = 2342467, upload-time = "2025-10-14T10:21:44.018Z" }, + { url = "https://files.pythonhosted.org/packages/92/97/4de0e2a1159cb85ad737e03306717637842c88c7fd6d97973172fb183149/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a44ac1738591472c3d020f61c6df1e4015180d6262ebd39bf2aeb52571b60f12", size = 2063754, upload-time = "2025-10-14T10:21:46.466Z" }, + { url = "https://files.pythonhosted.org/packages/0f/50/8cb90ce4b9efcf7ae78130afeb99fd1c86125ccdf9906ef64b9d42f37c25/pydantic_core-2.41.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d72f2b5e6e82ab8f94ea7d0d42f83c487dc159c5240d8f83beae684472864e2d", size = 2196754, upload-time = "2025-10-14T10:21:48.486Z" }, + { url = "https://files.pythonhosted.org/packages/34/3b/ccdc77af9cd5082723574a1cc1bcae7a6acacc829d7c0a06201f7886a109/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c4d1e854aaf044487d31143f541f7aafe7b482ae72a022c664b2de2e466ed0ad", size = 2137115, upload-time = "2025-10-14T10:21:50.63Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ba/e7c7a02651a8f7c52dc2cff2b64a30c313e3b57c7d93703cecea76c09b71/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b568af94267729d76e6ee5ececda4e283d07bbb28e8148bb17adad93d025d25a", size = 2317400, upload-time = "2025-10-14T10:21:52.959Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ba/6c533a4ee8aec6b812c643c49bb3bd88d3f01e3cebe451bb85512d37f00f/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6d55fb8b1e8929b341cc313a81a26e0d48aa3b519c1dbaadec3a6a2b4fcad025", size = 2312070, upload-time = "2025-10-14T10:21:55.419Z" }, + { url = "https://files.pythonhosted.org/packages/22/ae/f10524fcc0ab8d7f96cf9a74c880243576fd3e72bd8ce4f81e43d22bcab7/pydantic_core-2.41.4-cp314-cp314-win32.whl", hash = "sha256:5b66584e549e2e32a1398df11da2e0a7eff45d5c2d9db9d5667c5e6ac764d77e", size = 1982277, upload-time = "2025-10-14T10:21:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/b4/dc/e5aa27aea1ad4638f0c3fb41132f7eb583bd7420ee63204e2d4333a3bbf9/pydantic_core-2.41.4-cp314-cp314-win_amd64.whl", hash = "sha256:557a0aab88664cc552285316809cab897716a372afaf8efdbef756f8b890e894", size = 2024608, upload-time = "2025-10-14T10:21:59.557Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/51d89cc2612bd147198e120a13f150afbf0bcb4615cddb049ab10b81b79e/pydantic_core-2.41.4-cp314-cp314-win_arm64.whl", hash = "sha256:3f1ea6f48a045745d0d9f325989d8abd3f1eaf47dd00485912d1a3a63c623a8d", size = 1967614, upload-time = "2025-10-14T10:22:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c2/472f2e31b95eff099961fa050c376ab7156a81da194f9edb9f710f68787b/pydantic_core-2.41.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6c1fe4c5404c448b13188dd8bd2ebc2bdd7e6727fa61ff481bcc2cca894018da", size = 1876904, upload-time = "2025-10-14T10:22:04.062Z" }, + { url = "https://files.pythonhosted.org/packages/4a/07/ea8eeb91173807ecdae4f4a5f4b150a520085b35454350fc219ba79e66a3/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:523e7da4d43b113bf8e7b49fa4ec0c35bf4fe66b2230bfc5c13cc498f12c6c3e", size = 1882538, upload-time = "2025-10-14T10:22:06.39Z" }, + { url = "https://files.pythonhosted.org/packages/1e/29/b53a9ca6cd366bfc928823679c6a76c7a4c69f8201c0ba7903ad18ebae2f/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5729225de81fb65b70fdb1907fcf08c75d498f4a6f15af005aabb1fdadc19dfa", size = 2041183, upload-time = "2025-10-14T10:22:08.812Z" }, + { url = "https://files.pythonhosted.org/packages/c7/3d/f8c1a371ceebcaf94d6dd2d77c6cf4b1c078e13a5837aee83f760b4f7cfd/pydantic_core-2.41.4-cp314-cp314t-win_amd64.whl", hash = "sha256:de2cfbb09e88f0f795fd90cf955858fc2c691df65b1f21f0aa00b99f3fbc661d", size = 1993542, upload-time = "2025-10-14T10:22:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ac/9fc61b4f9d079482a290afe8d206b8f490e9fd32d4fc03ed4fc698214e01/pydantic_core-2.41.4-cp314-cp314t-win_arm64.whl", hash = "sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0", size = 1973897, upload-time = "2025-10-14T10:22:13.444Z" }, + { url = "https://files.pythonhosted.org/packages/b0/12/5ba58daa7f453454464f92b3ca7b9d7c657d8641c48e370c3ebc9a82dd78/pydantic_core-2.41.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:a1b2cfec3879afb742a7b0bcfa53e4f22ba96571c9e54d6a3afe1052d17d843b", size = 2122139, upload-time = "2025-10-14T10:22:47.288Z" }, + { url = "https://files.pythonhosted.org/packages/21/fb/6860126a77725c3108baecd10fd3d75fec25191d6381b6eb2ac660228eac/pydantic_core-2.41.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:d175600d975b7c244af6eb9c9041f10059f20b8bbffec9e33fdd5ee3f67cdc42", size = 1936674, upload-time = "2025-10-14T10:22:49.555Z" }, + { url = "https://files.pythonhosted.org/packages/de/be/57dcaa3ed595d81f8757e2b44a38240ac5d37628bce25fb20d02c7018776/pydantic_core-2.41.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f184d657fa4947ae5ec9c47bd7e917730fa1cbb78195037e32dcbab50aca5ee", size = 1956398, upload-time = "2025-10-14T10:22:52.19Z" }, + { url = "https://files.pythonhosted.org/packages/2f/1d/679a344fadb9695f1a6a294d739fbd21d71fa023286daeea8c0ed49e7c2b/pydantic_core-2.41.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ed810568aeffed3edc78910af32af911c835cc39ebbfacd1f0ab5dd53028e5c", size = 2138674, upload-time = "2025-10-14T10:22:54.499Z" }, + { url = "https://files.pythonhosted.org/packages/c4/48/ae937e5a831b7c0dc646b2ef788c27cd003894882415300ed21927c21efa/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:4f5d640aeebb438517150fdeec097739614421900e4a08db4a3ef38898798537", size = 2112087, upload-time = "2025-10-14T10:22:56.818Z" }, + { url = "https://files.pythonhosted.org/packages/5e/db/6db8073e3d32dae017da7e0d16a9ecb897d0a4d92e00634916e486097961/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:4a9ab037b71927babc6d9e7fc01aea9e66dc2a4a34dff06ef0724a4049629f94", size = 1920387, upload-time = "2025-10-14T10:22:59.342Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c1/dd3542d072fcc336030d66834872f0328727e3b8de289c662faa04aa270e/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4dab9484ec605c3016df9ad4fd4f9a390bc5d816a3b10c6550f8424bb80b18c", size = 1951495, upload-time = "2025-10-14T10:23:02.089Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c6/db8d13a1f8ab3f1eb08c88bd00fd62d44311e3456d1e85c0e59e0a0376e7/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8a5028425820731d8c6c098ab642d7b8b999758e24acae03ed38a66eca8335", size = 2139008, upload-time = "2025-10-14T10:23:04.539Z" }, + { url = "https://files.pythonhosted.org/packages/5d/d4/912e976a2dd0b49f31c98a060ca90b353f3b73ee3ea2fd0030412f6ac5ec/pydantic_core-2.41.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:1e5ab4fc177dd41536b3c32b2ea11380dd3d4619a385860621478ac2d25ceb00", size = 2106739, upload-time = "2025-10-14T10:23:06.934Z" }, + { url = "https://files.pythonhosted.org/packages/71/f0/66ec5a626c81eba326072d6ee2b127f8c139543f1bf609b4842978d37833/pydantic_core-2.41.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:3d88d0054d3fa11ce936184896bed3c1c5441d6fa483b498fac6a5d0dd6f64a9", size = 1932549, upload-time = "2025-10-14T10:23:09.24Z" }, + { url = "https://files.pythonhosted.org/packages/c4/af/625626278ca801ea0a658c2dcf290dc9f21bb383098e99e7c6a029fccfc0/pydantic_core-2.41.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b2a054a8725f05b4b6503357e0ac1c4e8234ad3b0c2ac130d6ffc66f0e170e2", size = 2135093, upload-time = "2025-10-14T10:23:11.626Z" }, + { url = "https://files.pythonhosted.org/packages/20/f6/2fba049f54e0f4975fef66be654c597a1d005320fa141863699180c7697d/pydantic_core-2.41.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b0d9db5a161c99375a0c68c058e227bee1d89303300802601d76a3d01f74e258", size = 2187971, upload-time = "2025-10-14T10:23:14.437Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/65ab839a2dfcd3b949202f9d920c34f9de5a537c3646662bdf2f7d999680/pydantic_core-2.41.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:6273ea2c8ffdac7b7fda2653c49682db815aebf4a89243a6feccf5e36c18c347", size = 2147939, upload-time = "2025-10-14T10:23:16.831Z" }, + { url = "https://files.pythonhosted.org/packages/44/58/627565d3d182ce6dfda18b8e1c841eede3629d59c9d7cbc1e12a03aeb328/pydantic_core-2.41.4-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:4c973add636efc61de22530b2ef83a65f39b6d6f656df97f678720e20de26caa", size = 2311400, upload-time = "2025-10-14T10:23:19.234Z" }, + { url = "https://files.pythonhosted.org/packages/24/06/8a84711162ad5a5f19a88cead37cca81b4b1f294f46260ef7334ae4f24d3/pydantic_core-2.41.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b69d1973354758007f46cf2d44a4f3d0933f10b6dc9bf15cf1356e037f6f731a", size = 2316840, upload-time = "2025-10-14T10:23:21.738Z" }, + { url = "https://files.pythonhosted.org/packages/aa/8b/b7bb512a4682a2f7fbfae152a755d37351743900226d29bd953aaf870eaa/pydantic_core-2.41.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:3619320641fd212aaf5997b6ca505e97540b7e16418f4a241f44cdf108ffb50d", size = 2149135, upload-time = "2025-10-14T10:23:24.379Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7d/138e902ed6399b866f7cfe4435d22445e16fff888a1c00560d9dc79a780f/pydantic_core-2.41.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:491535d45cd7ad7e4a2af4a5169b0d07bebf1adfd164b0368da8aa41e19907a5", size = 2104721, upload-time = "2025-10-14T10:23:26.906Z" }, + { url = "https://files.pythonhosted.org/packages/47/13/0525623cf94627f7b53b4c2034c81edc8491cbfc7c28d5447fa318791479/pydantic_core-2.41.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:54d86c0cada6aba4ec4c047d0e348cbad7063b87ae0f005d9f8c9ad04d4a92a2", size = 1931608, upload-time = "2025-10-14T10:23:29.306Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f9/744bc98137d6ef0a233f808bfc9b18cf94624bf30836a18d3b05d08bf418/pydantic_core-2.41.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eca1124aced216b2500dc2609eade086d718e8249cb9696660ab447d50a758bd", size = 2132986, upload-time = "2025-10-14T10:23:32.057Z" }, + { url = "https://files.pythonhosted.org/packages/17/c8/629e88920171173f6049386cc71f893dff03209a9ef32b4d2f7e7c264bcf/pydantic_core-2.41.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6c9024169becccf0cb470ada03ee578d7348c119a0d42af3dcf9eda96e3a247c", size = 2187516, upload-time = "2025-10-14T10:23:34.871Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0f/4f2734688d98488782218ca61bcc118329bf5de05bb7fe3adc7dd79b0b86/pydantic_core-2.41.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:26895a4268ae5a2849269f4991cdc97236e4b9c010e51137becf25182daac405", size = 2146146, upload-time = "2025-10-14T10:23:37.342Z" }, + { url = "https://files.pythonhosted.org/packages/ed/f2/ab385dbd94a052c62224b99cf99002eee99dbec40e10006c78575aead256/pydantic_core-2.41.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:ca4df25762cf71308c446e33c9b1fdca2923a3f13de616e2a949f38bf21ff5a8", size = 2311296, upload-time = "2025-10-14T10:23:40.145Z" }, + { url = "https://files.pythonhosted.org/packages/fc/8e/e4f12afe1beeb9823bba5375f8f258df0cc61b056b0195fb1cf9f62a1a58/pydantic_core-2.41.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:5a28fcedd762349519276c36634e71853b4541079cab4acaaac60c4421827308", size = 2315386, upload-time = "2025-10-14T10:23:42.624Z" }, + { url = "https://files.pythonhosted.org/packages/48/f7/925f65d930802e3ea2eb4d5afa4cb8730c8dc0d2cb89a59dc4ed2fcb2d74/pydantic_core-2.41.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c173ddcd86afd2535e2b695217e82191580663a1d1928239f877f5a1649ef39f", size = 2147775, upload-time = "2025-10-14T10:23:45.406Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/c5/dbbc27b814c71676593d1c3f718e6cd7d4f00652cefa24b75f7aa3efb25e/pydantic_settings-2.11.0.tar.gz", hash = "sha256:d0e87a1c7d33593beb7194adb8470fc426e95ba02af83a0f23474a04c9a08180", size = 188394, upload-time = "2025-09-24T14:19:11.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/d6/887a1ff844e64aa823fb4905978d882a633cfe295c32eacad582b78a7d8b/pydantic_settings-2.11.0-py3-none-any.whl", hash = "sha256:fe2cea3413b9530d10f3a5875adffb17ada5c1e1bab0b2885546d7310415207c", size = 48608, upload-time = "2025-09-24T14:19:10.015Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyperclip" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/40/44efbb0dfbd33aca6a6483191dae0716070ed99e2ecb0c53683f400a0b4f/pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3", size = 8760432, upload-time = "2025-07-14T20:13:05.9Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bf/360243b1e953bd254a82f12653974be395ba880e7ec23e3731d9f73921cc/pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b", size = 9590103, upload-time = "2025-07-14T20:13:07.698Z" }, + { url = "https://files.pythonhosted.org/packages/57/38/d290720e6f138086fb3d5ffe0b6caa019a791dd57866940c82e4eeaf2012/pywin32-311-cp310-cp310-win_arm64.whl", hash = "sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b", size = 8778557, upload-time = "2025-07-14T20:13:11.11Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, + { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "referencing" +version = "0.36.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744, upload-time = "2025-01-25T08:48:16.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775, upload-time = "2025-01-25T08:48:14.241Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "rfc3339-validator" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" }, +] + +[[package]] +name = "rich" +version = "14.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, +] + +[[package]] +name = "rich-rst" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/6d/a506aaa4a9eaa945ed8ab2b7347859f53593864289853c5d6d62b77246e0/rich_rst-1.3.2.tar.gz", hash = "sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4", size = 14936, upload-time = "2025-10-14T16:49:45.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl", hash = "sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a", size = 12567, upload-time = "2025-10-14T16:49:42.953Z" }, +] + +[[package]] +name = "ros-mcp" +version = "3.1.0" +source = { editable = "." } +dependencies = [ + { name = "fastmcp" }, + { name = "jsonschema" }, + { name = "mcp", extra = ["cli"] }, + { name = "opencv-python" }, + { name = "pillow" }, + { name = "websocket-client" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-timeout" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastmcp", specifier = ">=2.11.3" }, + { name = "jsonschema", specifier = ">=4.25.1" }, + { name = "mcp", extras = ["cli"], specifier = ">=1.13.0" }, + { name = "opencv-python", specifier = ">=4.11.0.86" }, + { name = "pillow", specifier = ">=11.3.0" }, + { name = "pytest", marker = "extra == 'dev'" }, + { name = "pytest-timeout", marker = "extra == 'dev'" }, + { name = "ruff", marker = "extra == 'dev'" }, + { name = "websocket-client", specifier = ">=1.8.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "rpds-py" +version = "0.28.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/48/dc/95f074d43452b3ef5d06276696ece4b3b5d696e7c9ad7173c54b1390cd70/rpds_py-0.28.0.tar.gz", hash = "sha256:abd4df20485a0983e2ca334a216249b6186d6e3c1627e106651943dbdb791aea", size = 27419, upload-time = "2025-10-22T22:24:29.327Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/f8/13bb772dc7cbf2c3c5b816febc34fa0cb2c64a08e0569869585684ce6631/rpds_py-0.28.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:7b6013db815417eeb56b2d9d7324e64fcd4fa289caeee6e7a78b2e11fc9b438a", size = 362820, upload-time = "2025-10-22T22:21:15.074Z" }, + { url = "https://files.pythonhosted.org/packages/84/91/6acce964aab32469c3dbe792cb041a752d64739c534e9c493c701ef0c032/rpds_py-0.28.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1a4c6b05c685c0c03f80dabaeb73e74218c49deea965ca63f76a752807397207", size = 348499, upload-time = "2025-10-22T22:21:17.658Z" }, + { url = "https://files.pythonhosted.org/packages/f1/93/c05bb1f4f5e0234db7c4917cb8dd5e2e0a9a7b26dc74b1b7bee3c9cfd477/rpds_py-0.28.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4794c6c3fbe8f9ac87699b131a1f26e7b4abcf6d828da46a3a52648c7930eba", size = 379356, upload-time = "2025-10-22T22:21:19.847Z" }, + { url = "https://files.pythonhosted.org/packages/5c/37/e292da436f0773e319753c567263427cdf6c645d30b44f09463ff8216cda/rpds_py-0.28.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2e8456b6ee5527112ff2354dd9087b030e3429e43a74f480d4a5ca79d269fd85", size = 390151, upload-time = "2025-10-22T22:21:21.569Z" }, + { url = "https://files.pythonhosted.org/packages/76/87/a4e3267131616e8faf10486dc00eaedf09bd61c87f01e5ef98e782ee06c9/rpds_py-0.28.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:beb880a9ca0a117415f241f66d56025c02037f7c4efc6fe59b5b8454f1eaa50d", size = 524831, upload-time = "2025-10-22T22:21:23.394Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c8/4a4ca76f0befae9515da3fad11038f0fce44f6bb60b21fe9d9364dd51fb0/rpds_py-0.28.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6897bebb118c44b38c9cb62a178e09f1593c949391b9a1a6fe777ccab5934ee7", size = 404687, upload-time = "2025-10-22T22:21:25.201Z" }, + { url = "https://files.pythonhosted.org/packages/6a/65/118afe854424456beafbbebc6b34dcf6d72eae3a08b4632bc4220f8240d9/rpds_py-0.28.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b553dd06e875249fd43efd727785efb57a53180e0fde321468222eabbeaafa", size = 382683, upload-time = "2025-10-22T22:21:26.536Z" }, + { url = "https://files.pythonhosted.org/packages/f7/bc/0625064041fb3a0c77ecc8878c0e8341b0ae27ad0f00cf8f2b57337a1e63/rpds_py-0.28.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:f0b2044fdddeea5b05df832e50d2a06fe61023acb44d76978e1b060206a8a476", size = 398927, upload-time = "2025-10-22T22:21:27.864Z" }, + { url = "https://files.pythonhosted.org/packages/5d/1a/fed7cf2f1ee8a5e4778f2054153f2cfcf517748875e2f5b21cf8907cd77d/rpds_py-0.28.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05cf1e74900e8da73fa08cc76c74a03345e5a3e37691d07cfe2092d7d8e27b04", size = 411590, upload-time = "2025-10-22T22:21:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/a8e0f67fa374a6c472dbb0afdaf1ef744724f165abb6899f20e2f1563137/rpds_py-0.28.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:efd489fec7c311dae25e94fe7eeda4b3d06be71c68f2cf2e8ef990ffcd2cd7e8", size = 559843, upload-time = "2025-10-22T22:21:30.917Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ea/e10353f6d7c105be09b8135b72787a65919971ae0330ad97d87e4e199880/rpds_py-0.28.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ada7754a10faacd4f26067e62de52d6af93b6d9542f0df73c57b9771eb3ba9c4", size = 584188, upload-time = "2025-10-22T22:21:32.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/b0/a19743e0763caf0c89f6fc6ba6fbd9a353b24ffb4256a492420c5517da5a/rpds_py-0.28.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c2a34fd26588949e1e7977cfcbb17a9a42c948c100cab890c6d8d823f0586457", size = 550052, upload-time = "2025-10-22T22:21:34.702Z" }, + { url = "https://files.pythonhosted.org/packages/de/bc/ec2c004f6c7d6ab1e25dae875cdb1aee087c3ebed5b73712ed3000e3851a/rpds_py-0.28.0-cp310-cp310-win32.whl", hash = "sha256:f9174471d6920cbc5e82a7822de8dfd4dcea86eb828b04fc8c6519a77b0ee51e", size = 215110, upload-time = "2025-10-22T22:21:36.645Z" }, + { url = "https://files.pythonhosted.org/packages/6c/de/4ce8abf59674e17187023933547d2018363e8fc76ada4f1d4d22871ccb6e/rpds_py-0.28.0-cp310-cp310-win_amd64.whl", hash = "sha256:6e32dd207e2c4f8475257a3540ab8a93eff997abfa0a3fdb287cae0d6cd874b8", size = 223850, upload-time = "2025-10-22T22:21:38.006Z" }, + { url = "https://files.pythonhosted.org/packages/a6/34/058d0db5471c6be7bef82487ad5021ff8d1d1d27794be8730aad938649cf/rpds_py-0.28.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:03065002fd2e287725d95fbc69688e0c6daf6c6314ba38bdbaa3895418e09296", size = 362344, upload-time = "2025-10-22T22:21:39.713Z" }, + { url = "https://files.pythonhosted.org/packages/5d/67/9503f0ec8c055a0782880f300c50a2b8e5e72eb1f94dfc2053da527444dd/rpds_py-0.28.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28ea02215f262b6d078daec0b45344c89e161eab9526b0d898221d96fdda5f27", size = 348440, upload-time = "2025-10-22T22:21:41.056Z" }, + { url = "https://files.pythonhosted.org/packages/68/2e/94223ee9b32332a41d75b6f94b37b4ce3e93878a556fc5f152cbd856a81f/rpds_py-0.28.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25dbade8fbf30bcc551cb352376c0ad64b067e4fc56f90e22ba70c3ce205988c", size = 379068, upload-time = "2025-10-22T22:21:42.593Z" }, + { url = "https://files.pythonhosted.org/packages/b4/25/54fd48f9f680cfc44e6a7f39a5fadf1d4a4a1fd0848076af4a43e79f998c/rpds_py-0.28.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c03002f54cc855860bfdc3442928ffdca9081e73b5b382ed0b9e8efe6e5e205", size = 390518, upload-time = "2025-10-22T22:21:43.998Z" }, + { url = "https://files.pythonhosted.org/packages/1b/85/ac258c9c27f2ccb1bd5d0697e53a82ebcf8088e3186d5d2bf8498ee7ed44/rpds_py-0.28.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9699fa7990368b22032baf2b2dce1f634388e4ffc03dfefaaac79f4695edc95", size = 525319, upload-time = "2025-10-22T22:21:45.645Z" }, + { url = "https://files.pythonhosted.org/packages/40/cb/c6734774789566d46775f193964b76627cd5f42ecf246d257ce84d1912ed/rpds_py-0.28.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b9b06fe1a75e05e0713f06ea0c89ecb6452210fd60e2f1b6ddc1067b990e08d9", size = 404896, upload-time = "2025-10-22T22:21:47.544Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/14e37ce83202c632c89b0691185dca9532288ff9d390eacae3d2ff771bae/rpds_py-0.28.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac9f83e7b326a3f9ec3ef84cda98fb0a74c7159f33e692032233046e7fd15da2", size = 382862, upload-time = "2025-10-22T22:21:49.176Z" }, + { url = "https://files.pythonhosted.org/packages/6a/83/f3642483ca971a54d60caa4449f9d6d4dbb56a53e0072d0deff51b38af74/rpds_py-0.28.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:0d3259ea9ad8743a75a43eb7819324cdab393263c91be86e2d1901ee65c314e0", size = 398848, upload-time = "2025-10-22T22:21:51.024Z" }, + { url = "https://files.pythonhosted.org/packages/44/09/2d9c8b2f88e399b4cfe86efdf2935feaf0394e4f14ab30c6c5945d60af7d/rpds_py-0.28.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9a7548b345f66f6695943b4ef6afe33ccd3f1b638bd9afd0f730dd255c249c9e", size = 412030, upload-time = "2025-10-22T22:21:52.665Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f5/e1cec473d4bde6df1fd3738be8e82d64dd0600868e76e92dfeaebbc2d18f/rpds_py-0.28.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c9a40040aa388b037eb39416710fbcce9443498d2eaab0b9b45ae988b53f5c67", size = 559700, upload-time = "2025-10-22T22:21:54.123Z" }, + { url = "https://files.pythonhosted.org/packages/8d/be/73bb241c1649edbf14e98e9e78899c2c5e52bbe47cb64811f44d2cc11808/rpds_py-0.28.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8f60c7ea34e78c199acd0d3cda37a99be2c861dd2b8cf67399784f70c9f8e57d", size = 584581, upload-time = "2025-10-22T22:21:56.102Z" }, + { url = "https://files.pythonhosted.org/packages/9c/9c/ffc6e9218cd1eb5c2c7dbd276c87cd10e8c2232c456b554169eb363381df/rpds_py-0.28.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1571ae4292649100d743b26d5f9c63503bb1fedf538a8f29a98dce2d5ba6b4e6", size = 549981, upload-time = "2025-10-22T22:21:58.253Z" }, + { url = "https://files.pythonhosted.org/packages/5f/50/da8b6d33803a94df0149345ee33e5d91ed4d25fc6517de6a25587eae4133/rpds_py-0.28.0-cp311-cp311-win32.whl", hash = "sha256:5cfa9af45e7c1140af7321fa0bef25b386ee9faa8928c80dc3a5360971a29e8c", size = 214729, upload-time = "2025-10-22T22:21:59.625Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/b0f48c4c320ee24c8c20df8b44acffb7353991ddf688af01eef5f93d7018/rpds_py-0.28.0-cp311-cp311-win_amd64.whl", hash = "sha256:dd8d86b5d29d1b74100982424ba53e56033dc47720a6de9ba0259cf81d7cecaa", size = 223977, upload-time = "2025-10-22T22:22:01.092Z" }, + { url = "https://files.pythonhosted.org/packages/b4/21/c8e77a2ac66e2ec4e21f18a04b4e9a0417ecf8e61b5eaeaa9360a91713b4/rpds_py-0.28.0-cp311-cp311-win_arm64.whl", hash = "sha256:4e27d3a5709cc2b3e013bf93679a849213c79ae0573f9b894b284b55e729e120", size = 217326, upload-time = "2025-10-22T22:22:02.944Z" }, + { url = "https://files.pythonhosted.org/packages/b8/5c/6c3936495003875fe7b14f90ea812841a08fca50ab26bd840e924097d9c8/rpds_py-0.28.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:6b4f28583a4f247ff60cd7bdda83db8c3f5b05a7a82ff20dd4b078571747708f", size = 366439, upload-time = "2025-10-22T22:22:04.525Z" }, + { url = "https://files.pythonhosted.org/packages/56/f9/a0f1ca194c50aa29895b442771f036a25b6c41a35e4f35b1a0ea713bedae/rpds_py-0.28.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d678e91b610c29c4b3d52a2c148b641df2b4676ffe47c59f6388d58b99cdc424", size = 348170, upload-time = "2025-10-22T22:22:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/18/ea/42d243d3a586beb72c77fa5def0487daf827210069a95f36328e869599ea/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e819e0e37a44a78e1383bf1970076e2ccc4dc8c2bbaa2f9bd1dc987e9afff628", size = 378838, upload-time = "2025-10-22T22:22:07.932Z" }, + { url = "https://files.pythonhosted.org/packages/e7/78/3de32e18a94791af8f33601402d9d4f39613136398658412a4e0b3047327/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5ee514e0f0523db5d3fb171f397c54875dbbd69760a414dccf9d4d7ad628b5bd", size = 393299, upload-time = "2025-10-22T22:22:09.435Z" }, + { url = "https://files.pythonhosted.org/packages/13/7e/4bdb435afb18acea2eb8a25ad56b956f28de7c59f8a1d32827effa0d4514/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3fa06d27fdcee47f07a39e02862da0100cb4982508f5ead53ec533cd5fe55e", size = 518000, upload-time = "2025-10-22T22:22:11.326Z" }, + { url = "https://files.pythonhosted.org/packages/31/d0/5f52a656875cdc60498ab035a7a0ac8f399890cc1ee73ebd567bac4e39ae/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:46959ef2e64f9e4a41fc89aa20dbca2b85531f9a72c21099a3360f35d10b0d5a", size = 408746, upload-time = "2025-10-22T22:22:13.143Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cd/49ce51767b879cde77e7ad9fae164ea15dce3616fe591d9ea1df51152706/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8455933b4bcd6e83fde3fefc987a023389c4b13f9a58c8d23e4b3f6d13f78c84", size = 386379, upload-time = "2025-10-22T22:22:14.602Z" }, + { url = "https://files.pythonhosted.org/packages/6a/99/e4e1e1ee93a98f72fc450e36c0e4d99c35370220e815288e3ecd2ec36a2a/rpds_py-0.28.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:ad50614a02c8c2962feebe6012b52f9802deec4263946cddea37aaf28dd25a66", size = 401280, upload-time = "2025-10-22T22:22:16.063Z" }, + { url = "https://files.pythonhosted.org/packages/61/35/e0c6a57488392a8b319d2200d03dad2b29c0db9996f5662c3b02d0b86c02/rpds_py-0.28.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e5deca01b271492553fdb6c7fd974659dce736a15bae5dad7ab8b93555bceb28", size = 412365, upload-time = "2025-10-22T22:22:17.504Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6a/841337980ea253ec797eb084665436007a1aad0faac1ba097fb906c5f69c/rpds_py-0.28.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:735f8495a13159ce6a0d533f01e8674cec0c57038c920495f87dcb20b3ddb48a", size = 559573, upload-time = "2025-10-22T22:22:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/e7/5e/64826ec58afd4c489731f8b00729c5f6afdb86f1df1df60bfede55d650bb/rpds_py-0.28.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:961ca621ff10d198bbe6ba4957decca61aa2a0c56695384c1d6b79bf61436df5", size = 583973, upload-time = "2025-10-22T22:22:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ee/44d024b4843f8386a4eeaa4c171b3d31d55f7177c415545fd1a24c249b5d/rpds_py-0.28.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2374e16cc9131022e7d9a8f8d65d261d9ba55048c78f3b6e017971a4f5e6353c", size = 553800, upload-time = "2025-10-22T22:22:22.25Z" }, + { url = "https://files.pythonhosted.org/packages/7d/89/33e675dccff11a06d4d85dbb4d1865f878d5020cbb69b2c1e7b2d3f82562/rpds_py-0.28.0-cp312-cp312-win32.whl", hash = "sha256:d15431e334fba488b081d47f30f091e5d03c18527c325386091f31718952fe08", size = 216954, upload-time = "2025-10-22T22:22:24.105Z" }, + { url = "https://files.pythonhosted.org/packages/af/36/45f6ebb3210887e8ee6dbf1bc710ae8400bb417ce165aaf3024b8360d999/rpds_py-0.28.0-cp312-cp312-win_amd64.whl", hash = "sha256:a410542d61fc54710f750d3764380b53bf09e8c4edbf2f9141a82aa774a04f7c", size = 227844, upload-time = "2025-10-22T22:22:25.551Z" }, + { url = "https://files.pythonhosted.org/packages/57/91/f3fb250d7e73de71080f9a221d19bd6a1c1eb0d12a1ea26513f6c1052ad6/rpds_py-0.28.0-cp312-cp312-win_arm64.whl", hash = "sha256:1f0cfd1c69e2d14f8c892b893997fa9a60d890a0c8a603e88dca4955f26d1edd", size = 217624, upload-time = "2025-10-22T22:22:26.914Z" }, + { url = "https://files.pythonhosted.org/packages/d3/03/ce566d92611dfac0085c2f4b048cd53ed7c274a5c05974b882a908d540a2/rpds_py-0.28.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e9e184408a0297086f880556b6168fa927d677716f83d3472ea333b42171ee3b", size = 366235, upload-time = "2025-10-22T22:22:28.397Z" }, + { url = "https://files.pythonhosted.org/packages/00/34/1c61da1b25592b86fd285bd7bd8422f4c9d748a7373b46126f9ae792a004/rpds_py-0.28.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:edd267266a9b0448f33dc465a97cfc5d467594b600fe28e7fa2f36450e03053a", size = 348241, upload-time = "2025-10-22T22:22:30.171Z" }, + { url = "https://files.pythonhosted.org/packages/fc/00/ed1e28616848c61c493a067779633ebf4b569eccaacf9ccbdc0e7cba2b9d/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85beb8b3f45e4e32f6802fb6cd6b17f615ef6c6a52f265371fb916fae02814aa", size = 378079, upload-time = "2025-10-22T22:22:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/11/b2/ccb30333a16a470091b6e50289adb4d3ec656fd9951ba8c5e3aaa0746a67/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d2412be8d00a1b895f8ad827cc2116455196e20ed994bb704bf138fe91a42724", size = 393151, upload-time = "2025-10-22T22:22:33.453Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d0/73e2217c3ee486d555cb84920597480627d8c0240ff3062005c6cc47773e/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cf128350d384b777da0e68796afdcebc2e9f63f0e9f242217754e647f6d32491", size = 517520, upload-time = "2025-10-22T22:22:34.949Z" }, + { url = "https://files.pythonhosted.org/packages/c4/91/23efe81c700427d0841a4ae7ea23e305654381831e6029499fe80be8a071/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a2036d09b363aa36695d1cc1a97b36865597f4478470b0697b5ee9403f4fe399", size = 408699, upload-time = "2025-10-22T22:22:36.584Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ee/a324d3198da151820a326c1f988caaa4f37fc27955148a76fff7a2d787a9/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8e1e9be4fa6305a16be628959188e4fd5cd6f1b0e724d63c6d8b2a8adf74ea6", size = 385720, upload-time = "2025-10-22T22:22:38.014Z" }, + { url = "https://files.pythonhosted.org/packages/19/ad/e68120dc05af8b7cab4a789fccd8cdcf0fe7e6581461038cc5c164cd97d2/rpds_py-0.28.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0a403460c9dd91a7f23fc3188de6d8977f1d9603a351d5db6cf20aaea95b538d", size = 401096, upload-time = "2025-10-22T22:22:39.869Z" }, + { url = "https://files.pythonhosted.org/packages/99/90/c1e070620042459d60df6356b666bb1f62198a89d68881816a7ed121595a/rpds_py-0.28.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d7366b6553cdc805abcc512b849a519167db8f5e5c3472010cd1228b224265cb", size = 411465, upload-time = "2025-10-22T22:22:41.395Z" }, + { url = "https://files.pythonhosted.org/packages/68/61/7c195b30d57f1b8d5970f600efee72a4fad79ec829057972e13a0370fd24/rpds_py-0.28.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5b43c6a3726efd50f18d8120ec0551241c38785b68952d240c45ea553912ac41", size = 558832, upload-time = "2025-10-22T22:22:42.871Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3d/06f3a718864773f69941d4deccdf18e5e47dd298b4628062f004c10f3b34/rpds_py-0.28.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0cb7203c7bc69d7c1585ebb33a2e6074492d2fc21ad28a7b9d40457ac2a51ab7", size = 583230, upload-time = "2025-10-22T22:22:44.877Z" }, + { url = "https://files.pythonhosted.org/packages/66/df/62fc783781a121e77fee9a21ead0a926f1b652280a33f5956a5e7833ed30/rpds_py-0.28.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a52a5169c664dfb495882adc75c304ae1d50df552fbd68e100fdc719dee4ff9", size = 553268, upload-time = "2025-10-22T22:22:46.441Z" }, + { url = "https://files.pythonhosted.org/packages/84/85/d34366e335140a4837902d3dea89b51f087bd6a63c993ebdff59e93ee61d/rpds_py-0.28.0-cp313-cp313-win32.whl", hash = "sha256:2e42456917b6687215b3e606ab46aa6bca040c77af7df9a08a6dcfe8a4d10ca5", size = 217100, upload-time = "2025-10-22T22:22:48.342Z" }, + { url = "https://files.pythonhosted.org/packages/3c/1c/f25a3f3752ad7601476e3eff395fe075e0f7813fbb9862bd67c82440e880/rpds_py-0.28.0-cp313-cp313-win_amd64.whl", hash = "sha256:e0a0311caedc8069d68fc2bf4c9019b58a2d5ce3cd7cb656c845f1615b577e1e", size = 227759, upload-time = "2025-10-22T22:22:50.219Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d6/5f39b42b99615b5bc2f36ab90423ea404830bdfee1c706820943e9a645eb/rpds_py-0.28.0-cp313-cp313-win_arm64.whl", hash = "sha256:04c1b207ab8b581108801528d59ad80aa83bb170b35b0ddffb29c20e411acdc1", size = 217326, upload-time = "2025-10-22T22:22:51.647Z" }, + { url = "https://files.pythonhosted.org/packages/5c/8b/0c69b72d1cee20a63db534be0df271effe715ef6c744fdf1ff23bb2b0b1c/rpds_py-0.28.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:f296ea3054e11fc58ad42e850e8b75c62d9a93a9f981ad04b2e5ae7d2186ff9c", size = 355736, upload-time = "2025-10-22T22:22:53.211Z" }, + { url = "https://files.pythonhosted.org/packages/f7/6d/0c2ee773cfb55c31a8514d2cece856dd299170a49babd50dcffb15ddc749/rpds_py-0.28.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5a7306c19b19005ad98468fcefeb7100b19c79fc23a5f24a12e06d91181193fa", size = 342677, upload-time = "2025-10-22T22:22:54.723Z" }, + { url = "https://files.pythonhosted.org/packages/e2/1c/22513ab25a27ea205144414724743e305e8153e6abe81833b5e678650f5a/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5d9b86aa501fed9862a443c5c3116f6ead8bc9296185f369277c42542bd646b", size = 371847, upload-time = "2025-10-22T22:22:56.295Z" }, + { url = "https://files.pythonhosted.org/packages/60/07/68e6ccdb4b05115ffe61d31afc94adef1833d3a72f76c9632d4d90d67954/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e5bbc701eff140ba0e872691d573b3d5d30059ea26e5785acba9132d10c8c31d", size = 381800, upload-time = "2025-10-22T22:22:57.808Z" }, + { url = "https://files.pythonhosted.org/packages/73/bf/6d6d15df80781d7f9f368e7c1a00caf764436518c4877fb28b029c4624af/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a5690671cd672a45aa8616d7374fdf334a1b9c04a0cac3c854b1136e92374fe", size = 518827, upload-time = "2025-10-22T22:22:59.826Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d3/2decbb2976cc452cbf12a2b0aaac5f1b9dc5dd9d1f7e2509a3ee00421249/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9f1d92ecea4fa12f978a367c32a5375a1982834649cdb96539dcdc12e609ab1a", size = 399471, upload-time = "2025-10-22T22:23:01.968Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2c/f30892f9e54bd02e5faca3f6a26d6933c51055e67d54818af90abed9748e/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d252db6b1a78d0a3928b6190156042d54c93660ce4d98290d7b16b5296fb7cc", size = 377578, upload-time = "2025-10-22T22:23:03.52Z" }, + { url = "https://files.pythonhosted.org/packages/f0/5d/3bce97e5534157318f29ac06bf2d279dae2674ec12f7cb9c12739cee64d8/rpds_py-0.28.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:d61b355c3275acb825f8777d6c4505f42b5007e357af500939d4a35b19177259", size = 390482, upload-time = "2025-10-22T22:23:05.391Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f0/886bd515ed457b5bd93b166175edb80a0b21a210c10e993392127f1e3931/rpds_py-0.28.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:acbe5e8b1026c0c580d0321c8aae4b0a1e1676861d48d6e8c6586625055b606a", size = 402447, upload-time = "2025-10-22T22:23:06.93Z" }, + { url = "https://files.pythonhosted.org/packages/42/b5/71e8777ac55e6af1f4f1c05b47542a1eaa6c33c1cf0d300dca6a1c6e159a/rpds_py-0.28.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8aa23b6f0fc59b85b4c7d89ba2965af274346f738e8d9fc2455763602e62fd5f", size = 552385, upload-time = "2025-10-22T22:23:08.557Z" }, + { url = "https://files.pythonhosted.org/packages/5d/cb/6ca2d70cbda5a8e36605e7788c4aa3bea7c17d71d213465a5a675079b98d/rpds_py-0.28.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7b14b0c680286958817c22d76fcbca4800ddacef6f678f3a7c79a1fe7067fe37", size = 575642, upload-time = "2025-10-22T22:23:10.348Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d4/407ad9960ca7856d7b25c96dcbe019270b5ffdd83a561787bc682c797086/rpds_py-0.28.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:bcf1d210dfee61a6c86551d67ee1031899c0fdbae88b2d44a569995d43797712", size = 544507, upload-time = "2025-10-22T22:23:12.434Z" }, + { url = "https://files.pythonhosted.org/packages/51/31/2f46fe0efcac23fbf5797c6b6b7e1c76f7d60773e525cb65fcbc582ee0f2/rpds_py-0.28.0-cp313-cp313t-win32.whl", hash = "sha256:3aa4dc0fdab4a7029ac63959a3ccf4ed605fee048ba67ce89ca3168da34a1342", size = 205376, upload-time = "2025-10-22T22:23:13.979Z" }, + { url = "https://files.pythonhosted.org/packages/92/e4/15947bda33cbedfc134490a41841ab8870a72a867a03d4969d886f6594a2/rpds_py-0.28.0-cp313-cp313t-win_amd64.whl", hash = "sha256:7b7d9d83c942855e4fdcfa75d4f96f6b9e272d42fffcb72cd4bb2577db2e2907", size = 215907, upload-time = "2025-10-22T22:23:15.5Z" }, + { url = "https://files.pythonhosted.org/packages/08/47/ffe8cd7a6a02833b10623bf765fbb57ce977e9a4318ca0e8cf97e9c3d2b3/rpds_py-0.28.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:dcdcb890b3ada98a03f9f2bb108489cdc7580176cb73b4f2d789e9a1dac1d472", size = 353830, upload-time = "2025-10-22T22:23:17.03Z" }, + { url = "https://files.pythonhosted.org/packages/f9/9f/890f36cbd83a58491d0d91ae0db1702639edb33fb48eeb356f80ecc6b000/rpds_py-0.28.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f274f56a926ba2dc02976ca5b11c32855cbd5925534e57cfe1fda64e04d1add2", size = 341819, upload-time = "2025-10-22T22:23:18.57Z" }, + { url = "https://files.pythonhosted.org/packages/09/e3/921eb109f682aa24fb76207698fbbcf9418738f35a40c21652c29053f23d/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fe0438ac4a29a520ea94c8c7f1754cdd8feb1bc490dfda1bfd990072363d527", size = 373127, upload-time = "2025-10-22T22:23:20.216Z" }, + { url = "https://files.pythonhosted.org/packages/23/13/bce4384d9f8f4989f1a9599c71b7a2d877462e5fd7175e1f69b398f729f4/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8a358a32dd3ae50e933347889b6af9a1bdf207ba5d1a3f34e1a38cd3540e6733", size = 382767, upload-time = "2025-10-22T22:23:21.787Z" }, + { url = "https://files.pythonhosted.org/packages/23/e1/579512b2d89a77c64ccef5a0bc46a6ef7f72ae0cf03d4b26dcd52e57ee0a/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e80848a71c78aa328fefaba9c244d588a342c8e03bda518447b624ea64d1ff56", size = 517585, upload-time = "2025-10-22T22:23:23.699Z" }, + { url = "https://files.pythonhosted.org/packages/62/3c/ca704b8d324a2591b0b0adcfcaadf9c862375b11f2f667ac03c61b4fd0a6/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f586db2e209d54fe177e58e0bc4946bea5fb0102f150b1b2f13de03e1f0976f8", size = 399828, upload-time = "2025-10-22T22:23:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/da/37/e84283b9e897e3adc46b4c88bb3f6ec92a43bd4d2f7ef5b13459963b2e9c/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ae8ee156d6b586e4292491e885d41483136ab994e719a13458055bec14cf370", size = 375509, upload-time = "2025-10-22T22:23:27.32Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c2/a980beab869d86258bf76ec42dec778ba98151f253a952b02fe36d72b29c/rpds_py-0.28.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a805e9b3973f7e27f7cab63a6b4f61d90f2e5557cff73b6e97cd5b8540276d3d", size = 392014, upload-time = "2025-10-22T22:23:29.332Z" }, + { url = "https://files.pythonhosted.org/packages/da/b5/b1d3c5f9d3fa5aeef74265f9c64de3c34a0d6d5cd3c81c8b17d5c8f10ed4/rpds_py-0.28.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5d3fd16b6dc89c73a4da0b4ac8b12a7ecc75b2864b95c9e5afed8003cb50a728", size = 402410, upload-time = "2025-10-22T22:23:31.14Z" }, + { url = "https://files.pythonhosted.org/packages/74/ae/cab05ff08dfcc052afc73dcb38cbc765ffc86f94e966f3924cd17492293c/rpds_py-0.28.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6796079e5d24fdaba6d49bda28e2c47347e89834678f2bc2c1b4fc1489c0fb01", size = 553593, upload-time = "2025-10-22T22:23:32.834Z" }, + { url = "https://files.pythonhosted.org/packages/70/80/50d5706ea2a9bfc9e9c5f401d91879e7c790c619969369800cde202da214/rpds_py-0.28.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:76500820c2af232435cbe215e3324c75b950a027134e044423f59f5b9a1ba515", size = 576925, upload-time = "2025-10-22T22:23:34.47Z" }, + { url = "https://files.pythonhosted.org/packages/ab/12/85a57d7a5855a3b188d024b099fd09c90db55d32a03626d0ed16352413ff/rpds_py-0.28.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bbdc5640900a7dbf9dd707fe6388972f5bbd883633eb68b76591044cfe346f7e", size = 542444, upload-time = "2025-10-22T22:23:36.093Z" }, + { url = "https://files.pythonhosted.org/packages/6c/65/10643fb50179509150eb94d558e8837c57ca8b9adc04bd07b98e57b48f8c/rpds_py-0.28.0-cp314-cp314-win32.whl", hash = "sha256:adc8aa88486857d2b35d75f0640b949759f79dc105f50aa2c27816b2e0dd749f", size = 207968, upload-time = "2025-10-22T22:23:37.638Z" }, + { url = "https://files.pythonhosted.org/packages/b4/84/0c11fe4d9aaea784ff4652499e365963222481ac647bcd0251c88af646eb/rpds_py-0.28.0-cp314-cp314-win_amd64.whl", hash = "sha256:66e6fa8e075b58946e76a78e69e1a124a21d9a48a5b4766d15ba5b06869d1fa1", size = 218876, upload-time = "2025-10-22T22:23:39.179Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e0/3ab3b86ded7bb18478392dc3e835f7b754cd446f62f3fc96f4fe2aca78f6/rpds_py-0.28.0-cp314-cp314-win_arm64.whl", hash = "sha256:a6fe887c2c5c59413353b7c0caff25d0e566623501ccfff88957fa438a69377d", size = 212506, upload-time = "2025-10-22T22:23:40.755Z" }, + { url = "https://files.pythonhosted.org/packages/51/ec/d5681bb425226c3501eab50fc30e9d275de20c131869322c8a1729c7b61c/rpds_py-0.28.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7a69df082db13c7070f7b8b1f155fa9e687f1d6aefb7b0e3f7231653b79a067b", size = 355433, upload-time = "2025-10-22T22:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/be/ec/568c5e689e1cfb1ea8b875cffea3649260955f677fdd7ddc6176902d04cd/rpds_py-0.28.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b1cde22f2c30ebb049a9e74c5374994157b9b70a16147d332f89c99c5960737a", size = 342601, upload-time = "2025-10-22T22:23:44.372Z" }, + { url = "https://files.pythonhosted.org/packages/32/fe/51ada84d1d2a1d9d8f2c902cfddd0133b4a5eb543196ab5161d1c07ed2ad/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5338742f6ba7a51012ea470bd4dc600a8c713c0c72adaa0977a1b1f4327d6592", size = 372039, upload-time = "2025-10-22T22:23:46.025Z" }, + { url = "https://files.pythonhosted.org/packages/07/c1/60144a2f2620abade1a78e0d91b298ac2d9b91bc08864493fa00451ef06e/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e1460ebde1bcf6d496d80b191d854adedcc619f84ff17dc1c6d550f58c9efbba", size = 382407, upload-time = "2025-10-22T22:23:48.098Z" }, + { url = "https://files.pythonhosted.org/packages/45/ed/091a7bbdcf4038a60a461df50bc4c82a7ed6d5d5e27649aab61771c17585/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e3eb248f2feba84c692579257a043a7699e28a77d86c77b032c1d9fbb3f0219c", size = 518172, upload-time = "2025-10-22T22:23:50.16Z" }, + { url = "https://files.pythonhosted.org/packages/54/dd/02cc90c2fd9c2ef8016fd7813bfacd1c3a1325633ec8f244c47b449fc868/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3bbba5def70b16cd1c1d7255666aad3b290fbf8d0fe7f9f91abafb73611a91", size = 399020, upload-time = "2025-10-22T22:23:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/ab/81/5d98cc0329bbb911ccecd0b9e19fbf7f3a5de8094b4cda5e71013b2dd77e/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3114f4db69ac5a1f32e7e4d1cbbe7c8f9cf8217f78e6e002cedf2d54c2a548ed", size = 377451, upload-time = "2025-10-22T22:23:53.711Z" }, + { url = "https://files.pythonhosted.org/packages/b4/07/4d5bcd49e3dfed2d38e2dcb49ab6615f2ceb9f89f5a372c46dbdebb4e028/rpds_py-0.28.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4b0cb8a906b1a0196b863d460c0222fb8ad0f34041568da5620f9799b83ccf0b", size = 390355, upload-time = "2025-10-22T22:23:55.299Z" }, + { url = "https://files.pythonhosted.org/packages/3f/79/9f14ba9010fee74e4f40bf578735cfcbb91d2e642ffd1abe429bb0b96364/rpds_py-0.28.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cf681ac76a60b667106141e11a92a3330890257e6f559ca995fbb5265160b56e", size = 403146, upload-time = "2025-10-22T22:23:56.929Z" }, + { url = "https://files.pythonhosted.org/packages/39/4c/f08283a82ac141331a83a40652830edd3a4a92c34e07e2bbe00baaea2f5f/rpds_py-0.28.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1e8ee6413cfc677ce8898d9cde18cc3a60fc2ba756b0dec5b71eb6eb21c49fa1", size = 552656, upload-time = "2025-10-22T22:23:58.62Z" }, + { url = "https://files.pythonhosted.org/packages/61/47/d922fc0666f0dd8e40c33990d055f4cc6ecff6f502c2d01569dbed830f9b/rpds_py-0.28.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b3072b16904d0b5572a15eb9d31c1954e0d3227a585fc1351aa9878729099d6c", size = 576782, upload-time = "2025-10-22T22:24:00.312Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0c/5bafdd8ccf6aa9d3bfc630cfece457ff5b581af24f46a9f3590f790e3df2/rpds_py-0.28.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b670c30fd87a6aec281c3c9896d3bae4b205fd75d79d06dc87c2503717e46092", size = 544671, upload-time = "2025-10-22T22:24:02.297Z" }, + { url = "https://files.pythonhosted.org/packages/2c/37/dcc5d8397caa924988693519069d0beea077a866128719351a4ad95e82fc/rpds_py-0.28.0-cp314-cp314t-win32.whl", hash = "sha256:8014045a15b4d2b3476f0a287fcc93d4f823472d7d1308d47884ecac9e612be3", size = 205749, upload-time = "2025-10-22T22:24:03.848Z" }, + { url = "https://files.pythonhosted.org/packages/d7/69/64d43b21a10d72b45939a28961216baeb721cc2a430f5f7c3bfa21659a53/rpds_py-0.28.0-cp314-cp314t-win_amd64.whl", hash = "sha256:7a4e59c90d9c27c561eb3160323634a9ff50b04e4f7820600a2beb0ac90db578", size = 216233, upload-time = "2025-10-22T22:24:05.471Z" }, + { url = "https://files.pythonhosted.org/packages/ae/bc/b43f2ea505f28119bd551ae75f70be0c803d2dbcd37c1b3734909e40620b/rpds_py-0.28.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f5e7101145427087e493b9c9b959da68d357c28c562792300dd21a095118ed16", size = 363913, upload-time = "2025-10-22T22:24:07.129Z" }, + { url = "https://files.pythonhosted.org/packages/28/f2/db318195d324c89a2c57dc5195058cbadd71b20d220685c5bd1da79ee7fe/rpds_py-0.28.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:31eb671150b9c62409a888850aaa8e6533635704fe2b78335f9aaf7ff81eec4d", size = 350452, upload-time = "2025-10-22T22:24:08.754Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f2/1391c819b8573a4898cedd6b6c5ec5bc370ce59e5d6bdcebe3c9c1db4588/rpds_py-0.28.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48b55c1f64482f7d8bd39942f376bfdf2f6aec637ee8c805b5041e14eeb771db", size = 380957, upload-time = "2025-10-22T22:24:10.826Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5c/e5de68ee7eb7248fce93269833d1b329a196d736aefb1a7481d1e99d1222/rpds_py-0.28.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24743a7b372e9a76171f6b69c01aedf927e8ac3e16c474d9fe20d552a8cb45c7", size = 391919, upload-time = "2025-10-22T22:24:12.559Z" }, + { url = "https://files.pythonhosted.org/packages/fb/4f/2376336112cbfeb122fd435d608ad8d5041b3aed176f85a3cb32c262eb80/rpds_py-0.28.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:389c29045ee8bbb1627ea190b4976a310a295559eaf9f1464a1a6f2bf84dde78", size = 528541, upload-time = "2025-10-22T22:24:14.197Z" }, + { url = "https://files.pythonhosted.org/packages/68/53/5ae232e795853dd20da7225c5dd13a09c0a905b1a655e92bdf8d78a99fd9/rpds_py-0.28.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:23690b5827e643150cf7b49569679ec13fe9a610a15949ed48b85eb7f98f34ec", size = 405629, upload-time = "2025-10-22T22:24:16.001Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2d/351a3b852b683ca9b6b8b38ed9efb2347596973849ba6c3a0e99877c10aa/rpds_py-0.28.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f0c9266c26580e7243ad0d72fc3e01d6b33866cfab5084a6da7576bcf1c4f72", size = 384123, upload-time = "2025-10-22T22:24:17.585Z" }, + { url = "https://files.pythonhosted.org/packages/e0/15/870804daa00202728cc91cb8e2385fa9f1f4eb49857c49cfce89e304eae6/rpds_py-0.28.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:4c6c4db5d73d179746951486df97fd25e92396be07fc29ee8ff9a8f5afbdfb27", size = 400923, upload-time = "2025-10-22T22:24:19.512Z" }, + { url = "https://files.pythonhosted.org/packages/53/25/3706b83c125fa2a0bccceac951de3f76631f6bd0ee4d02a0ed780712ef1b/rpds_py-0.28.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3b695a8fa799dd2cfdb4804b37096c5f6dba1ac7f48a7fbf6d0485bcd060316", size = 413767, upload-time = "2025-10-22T22:24:21.316Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f9/ce43dbe62767432273ed2584cef71fef8411bddfb64125d4c19128015018/rpds_py-0.28.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:6aa1bfce3f83baf00d9c5fcdbba93a3ab79958b4c7d7d1f55e7fe68c20e63912", size = 561530, upload-time = "2025-10-22T22:24:22.958Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/ffe77999ed8f81e30713dd38fd9ecaa161f28ec48bb80fa1cd9118399c27/rpds_py-0.28.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:7b0f9dceb221792b3ee6acb5438eb1f02b0cb2c247796a72b016dcc92c6de829", size = 585453, upload-time = "2025-10-22T22:24:24.779Z" }, + { url = "https://files.pythonhosted.org/packages/ed/d2/4a73b18821fd4669762c855fd1f4e80ceb66fb72d71162d14da58444a763/rpds_py-0.28.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:5d0145edba8abd3db0ab22b5300c99dc152f5c9021fab861be0f0544dc3cbc5f", size = 552199, upload-time = "2025-10-22T22:24:26.54Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/34/8218a19b2055b80601e8fd201ec723c74c7fe1ca06d525a43ed07b6d8e85/ruff-0.14.2.tar.gz", hash = "sha256:98da787668f239313d9c902ca7c523fe11b8ec3f39345553a51b25abc4629c96", size = 5539663, upload-time = "2025-10-23T19:37:00.956Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/dd/23eb2db5ad9acae7c845700493b72d3ae214dce0b226f27df89216110f2b/ruff-0.14.2-py3-none-linux_armv6l.whl", hash = "sha256:7cbe4e593505bdec5884c2d0a4d791a90301bc23e49a6b1eb642dd85ef9c64f1", size = 12533390, upload-time = "2025-10-23T19:36:18.044Z" }, + { url = "https://files.pythonhosted.org/packages/5a/8c/5f9acff43ddcf3f85130d0146d0477e28ccecc495f9f684f8f7119b74c0d/ruff-0.14.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8d54b561729cee92f8d89c316ad7a3f9705533f5903b042399b6ae0ddfc62e11", size = 12887187, upload-time = "2025-10-23T19:36:22.664Z" }, + { url = "https://files.pythonhosted.org/packages/99/fa/047646491479074029665022e9f3dc6f0515797f40a4b6014ea8474c539d/ruff-0.14.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5c8753dfa44ebb2cde10ce5b4d2ef55a41fb9d9b16732a2c5df64620dbda44a3", size = 11925177, upload-time = "2025-10-23T19:36:24.778Z" }, + { url = "https://files.pythonhosted.org/packages/15/8b/c44cf7fe6e59ab24a9d939493a11030b503bdc2a16622cede8b7b1df0114/ruff-0.14.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d0bbeffb8d9f4fccf7b5198d566d0bad99a9cb622f1fc3467af96cb8773c9e3", size = 12358285, upload-time = "2025-10-23T19:36:26.979Z" }, + { url = "https://files.pythonhosted.org/packages/45/01/47701b26254267ef40369aea3acb62a7b23e921c27372d127e0f3af48092/ruff-0.14.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7047f0c5a713a401e43a88d36843d9c83a19c584e63d664474675620aaa634a8", size = 12303832, upload-time = "2025-10-23T19:36:29.192Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5c/ae7244ca4fbdf2bee9d6405dcd5bc6ae51ee1df66eb7a9884b77b8af856d/ruff-0.14.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bf8d2f9aa1602599217d82e8e0af7fd33e5878c4d98f37906b7c93f46f9a839", size = 13036995, upload-time = "2025-10-23T19:36:31.861Z" }, + { url = "https://files.pythonhosted.org/packages/27/4c/0860a79ce6fd4c709ac01173f76f929d53f59748d0dcdd662519835dae43/ruff-0.14.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1c505b389e19c57a317cf4b42db824e2fca96ffb3d86766c1c9f8b96d32048a7", size = 14512649, upload-time = "2025-10-23T19:36:33.915Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7f/d365de998069720a3abfc250ddd876fc4b81a403a766c74ff9bde15b5378/ruff-0.14.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a307fc45ebd887b3f26b36d9326bb70bf69b01561950cdcc6c0bdf7bb8e0f7cc", size = 14088182, upload-time = "2025-10-23T19:36:36.983Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ea/d8e3e6b209162000a7be1faa41b0a0c16a133010311edc3329753cc6596a/ruff-0.14.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:61ae91a32c853172f832c2f40bd05fd69f491db7289fb85a9b941ebdd549781a", size = 13599516, upload-time = "2025-10-23T19:36:39.208Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ea/c7810322086db68989fb20a8d5221dd3b79e49e396b01badca07b433ab45/ruff-0.14.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1967e40286f63ee23c615e8e7e98098dedc7301568bd88991f6e544d8ae096", size = 13272690, upload-time = "2025-10-23T19:36:41.453Z" }, + { url = "https://files.pythonhosted.org/packages/a9/39/10b05acf8c45786ef501d454e00937e1b97964f846bf28883d1f9619928a/ruff-0.14.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2877f02119cdebf52a632d743a2e302dea422bfae152ebe2f193d3285a3a65df", size = 13496497, upload-time = "2025-10-23T19:36:43.61Z" }, + { url = "https://files.pythonhosted.org/packages/59/a1/1f25f8301e13751c30895092485fada29076e5e14264bdacc37202e85d24/ruff-0.14.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e681c5bc777de5af898decdcb6ba3321d0d466f4cb43c3e7cc2c3b4e7b843a05", size = 12266116, upload-time = "2025-10-23T19:36:45.625Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/0029bfc9ce16ae78164e6923ef392e5f173b793b26cc39aa1d8b366cf9dc/ruff-0.14.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e21be42d72e224736f0c992cdb9959a2fa53c7e943b97ef5d081e13170e3ffc5", size = 12281345, upload-time = "2025-10-23T19:36:47.618Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ab/ece7baa3c0f29b7683be868c024f0838770c16607bea6852e46b202f1ff6/ruff-0.14.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b8264016f6f209fac16262882dbebf3f8be1629777cf0f37e7aff071b3e9b92e", size = 12629296, upload-time = "2025-10-23T19:36:49.789Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7f/638f54b43f3d4e48c6a68062794e5b367ddac778051806b9e235dfb7aa81/ruff-0.14.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5ca36b4cb4db3067a3b24444463ceea5565ea78b95fe9a07ca7cb7fd16948770", size = 13371610, upload-time = "2025-10-23T19:36:51.882Z" }, + { url = "https://files.pythonhosted.org/packages/8d/35/3654a973ebe5b32e1fd4a08ed2d46755af7267da7ac710d97420d7b8657d/ruff-0.14.2-py3-none-win32.whl", hash = "sha256:41775927d287685e08f48d8eb3f765625ab0b7042cc9377e20e64f4eb0056ee9", size = 12415318, upload-time = "2025-10-23T19:36:53.961Z" }, + { url = "https://files.pythonhosted.org/packages/71/30/3758bcf9e0b6a4193a6f51abf84254aba00887dfa8c20aba18aa366c5f57/ruff-0.14.2-py3-none-win_amd64.whl", hash = "sha256:0df3424aa5c3c08b34ed8ce099df1021e3adaca6e90229273496b839e5a7e1af", size = 13565279, upload-time = "2025-10-23T19:36:56.578Z" }, + { url = "https://files.pythonhosted.org/packages/2e/5d/aa883766f8ef9ffbe6aa24f7192fb71632f31a30e77eb39aa2b0dc4290ac/ruff-0.14.2-py3-none-win_arm64.whl", hash = "sha256:ea9d635e83ba21569fbacda7e78afbfeb94911c9434aff06192d9bc23fd5495a", size = 12554956, upload-time = "2025-10-23T19:36:58.714Z" }, +] + +[[package]] +name = "secretstorage" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/9f/11ef35cf1027c1339552ea7bfe6aaa74a8516d8b5caf6e7d338daf54fd80/secretstorage-3.4.0.tar.gz", hash = "sha256:c46e216d6815aff8a8a18706a2fbfd8d53fcbb0dce99301881687a1b0289ef7c", size = 19748, upload-time = "2025-09-09T16:42:13.859Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/ff/2e2eed29e02c14a5cb6c57f09b2d5b40e65d6cc71f45b52e0be295ccbc2f/secretstorage-3.4.0-py3-none-any.whl", hash = "sha256:0e3b6265c2c63509fb7415717607e4b2c9ab767b7f344a57473b779ca13bd02e", size = 15272, upload-time = "2025-09-09T16:42:12.744Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[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 = "sse-starlette" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/6f/22ed6e33f8a9e76ca0a412405f31abb844b779d52c5f96660766edcd737c/sse_starlette-3.0.2.tar.gz", hash = "sha256:ccd60b5765ebb3584d0de2d7a6e4f745672581de4f5005ab31c3a25d10b52b3a", size = 20985, upload-time = "2025-07-27T09:07:44.565Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/10/c78f463b4ef22eef8491f218f692be838282cd65480f6e423d7730dfd1fb/sse_starlette-3.0.2-py3-none-any.whl", hash = "sha256:16b7cbfddbcd4eaca11f7b586f3b8a080f1afe952c15813455b162edea619e5a", size = 11297, upload-time = "2025-07-27T09:07:43.268Z" }, +] + +[[package]] +name = "starlette" +version = "0.48.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/a5/d6f429d43394057b67a6b5bbe6eae2f77a6bf7459d961fdb224bf206eee6/starlette-0.48.0.tar.gz", hash = "sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46", size = 2652949, upload-time = "2025-09-13T08:41:05.699Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/72/2db2f49247d0a18b4f1bb9a5a39a0162869acf235f3a96418363947b3d46/starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659", size = 73736, upload-time = "2025-09-13T08:41:03.869Z" }, +] + +[[package]] +name = "tomli" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, + { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, + { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, + { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, + { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, + { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, + { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, + { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, + { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, + { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, + { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, + { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, + { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, + { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, + { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, + { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, + { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, + { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, + { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, + { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, +] + +[[package]] +name = "typer" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/28/7c85c8032b91dbe79725b6f17d2fffc595dff06a35c7a30a37bef73a1ab4/typer-0.20.0.tar.gz", hash = "sha256:1aaf6494031793e4876fb0bacfa6a912b551cf43c1e63c800df8b1a866720c37", size = 106492, upload-time = "2025-10-20T17:03:49.445Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl", hash = "sha256:5b463df6793ec1dca6213a3cf4c0f03bc6e322ac5e16e13ddd622a889489784a", size = 47028, upload-time = "2025-10-20T17:03:47.617Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] + +[[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/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" }, + { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" }, + { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" }, + { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" }, + { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, + { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, + { 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/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" }, + { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" }, + { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" }, + { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" }, + { 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" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/af/d4502dc713b4ccea7175d764718d5183caf8d0867a4f0190d5d4a45cea49/werkzeug-3.1.1.tar.gz", hash = "sha256:8cd39dfbdfc1e051965f156163e2974e52c210f130810e9ad36858f0fd3edad4", size = 806453, upload-time = "2024-11-01T16:40:45.462Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/ea/c67e1dee1ba208ed22c06d1d547ae5e293374bfc43e0eb0ef5e262b68561/werkzeug-3.1.1-py3-none-any.whl", hash = "sha256:a71124d1ef06008baafa3d266c02f56e1836a5984afd6dd6c9230669d60d9fb5", size = 224371, upload-time = "2024-11-01T16:40:43.994Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +]